diff --git a/ISSUE_TEMPLATE b/ISSUE_TEMPLATE index 3667c8cc96..6e55f3dcd6 100644 --- a/ISSUE_TEMPLATE +++ b/ISSUE_TEMPLATE @@ -1,5 +1,7 @@ +*** PLEASE DO NOT IGNORE THIS ISSUE TEMPLATE *** + Please search the existing issues before filing a new one, including issues that -are closed. When filing a new issue please include all of the following, unless +are closed. When filing a new issue please include ALL of the following, unless you're certain that they're not useful for the particular issue being reported. - A description of the issue. diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ce002238ef..fa6c42ca88 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,9 +1,68 @@ # Release notes # +### r2.1.0 ### + +This release contains important bug fixes. Users of r2.0.x should proactively +update to this version. + +* HLS: Support for seeking in live streams + ([87](https://github.com/google/ExoPlayer/issues/87)). +* HLS: Improved support: + * Support for EXT-X-PROGRAM-DATE-TIME + ([747](https://github.com/google/ExoPlayer/issues/747)). + * Improved handling of sample timestamps and their alignment across variants + and renditions. + * Fix issue that could cause playbacks to get stuck in an endless initial + buffering state. + * Correctly propagate BehindLiveWindowException instead of + IndexOutOfBoundsException exception + ([1695](https://github.com/google/ExoPlayer/issues/1695)). +* MP3/MP4: Support for ID3 metadata, including embedded album art + ([979](https://github.com/google/ExoPlayer/issues/979)). +* Improved customization of UI components. You can read about customization of + ExoPlayer's UI components + [here](https://medium.com/google-exoplayer/customizing-exoplayers-ui-components-728cf55ee07a#.9ewjg7avi). +* Robustness improvements when handling MediaSource timeline changes and + MediaPeriod transitions. +* EIA608: Support for caption styling and positioning. +* MPEG-TS: Improved support: + * Support injection of custom TS payload readers. + * Support injection of custom section payload readers. + * Support SCTE-35 splice information messages. + * Support multiple table sections in a single PSI section. + * Fix NullPointerException when an unsupported stream type is encountered + ([2149](https://github.com/google/ExoPlayer/issues/2149)). + * Avoid failure when expected ID3 header not found + ([1966](https://github.com/google/ExoPlayer/issues/1966)). +* Improvements to the upstream cache package. + * Support caching of media segments for DASH, HLS and SmoothStreaming. Note + that caching of manifest and playlist files is still not supported in the + (normal) case where the corresponding responses are compressed. + * Support caching for ExtractorMediaSource based playbacks. +* Improved flexibility of SimpleExoPlayer + ([2102](https://github.com/google/ExoPlayer/issues/2102)). +* Fix issue where only the audio of a video would play due to capability + detection issues ([2007](https://github.com/google/ExoPlayer/issues/2007)) + ([2034](https://github.com/google/ExoPlayer/issues/2034)) + ([2157](https://github.com/google/ExoPlayer/issues/2157)). +* Fix issues that could cause ExtractorMediaSource based playbacks to get stuck + buffering ([1962](https://github.com/google/ExoPlayer/issues/1962)). +* Correctly set SimpleExoPlayerView surface aspect ratio when an active player + is attached ([2077](https://github.com/google/ExoPlayer/issues/1976)). +* OGG: Fix playback of short OGG files + ([1976](https://github.com/google/ExoPlayer/issues/1976)). +* MP4: Support `.mp3` tracks + ([2066](https://github.com/google/ExoPlayer/issues/2066)). +* SubRip: Don't fail playbacks if SubRip file contains negative timestamps + ([2145](https://github.com/google/ExoPlayer/issues/2145)). +* Misc bugfixes. + ### r2.0.4 ### -This release contains important bug fixes. Users of earlier r2.0.x versions -should proactively update to this version. +* Fix crash on Jellybean devices when using playback controls + ([#1965](https://github.com/google/ExoPlayer/issues/1965)). + +### r2.0.3 ### * Fix crash on Jellybean devices when using playback controls ([#1965](https://github.com/google/ExoPlayer/issues/1965)). @@ -113,6 +172,26 @@ some of the motivations behind ExoPlayer 2.x * Suppressed "Sending message to a Handler on a dead thread" warnings ([#426](https://github.com/google/ExoPlayer/issues/426)). +# Legacy release notes # + +Note: Since ExoPlayer V1 is still being maintained alongside V2, there is some +overlap between these notes and the notes above. r2.0.0 followed from r1.5.11, +and hence it can be assumed that all changes in r1.5.11 and earlier are included +in all V2 releases. This cannot be assumed for changes in r1.5.12 and later, +however it can be assumed that all such changes are included in the most recent +V2 release. + +### r1.5.13 ### + +* Improvements to the upstream cache package. +* MP4: Support `.mp3` tracks + ([2066](https://github.com/google/ExoPlayer/issues/2066)). +* SubRip: Don't fail playbacks if SubRip file contains negative timestamps + ([2145](https://github.com/google/ExoPlayer/issues/2145)). +* MPEG-TS: Avoid failure when expected ID3 header not found + ([1966](https://github.com/google/ExoPlayer/issues/1966)). +* Misc bugfixes. + ### r1.5.12 ### * Improvements to Cronet network stack extension. diff --git a/build.gradle b/build.gradle index 8e9032be70..0ea3ad66f3 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.1.2' + classpath 'com.android.tools.build:gradle:2.2.1' classpath 'com.novoda:bintray-release:0.3.4' } } @@ -35,7 +35,7 @@ allprojects { releaseRepoName = 'exoplayer' releaseUserOrg = 'google' releaseGroupId = 'com.google.android.exoplayer' - releaseVersion = 'r2.0.4' + releaseVersion = 'r2.1.0' releaseWebsite = 'https://github.com/google/ExoPlayer' } } diff --git a/demo/build.gradle b/demo/build.gradle index bfbcd1aa4c..27180682fa 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -41,6 +41,7 @@ android { noExtensions withExtensions } + } dependencies { diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 1f015827c9..d1b44abafe 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -16,17 +16,19 @@ + android:versionCode="2100" + android:versionName="2.1.0"> - + + @@ -37,6 +39,7 @@ + diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index 92dc08597f..b5db4c018d 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -36,13 +36,17 @@ public class DemoApplication extends Application { userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); } - DataSource.Factory buildDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { + public DataSource.Factory buildDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { return new DefaultDataSourceFactory(this, bandwidthMeter, buildHttpDataSourceFactory(bandwidthMeter)); } - HttpDataSource.Factory buildHttpDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { + public HttpDataSource.Factory buildHttpDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { return new DefaultHttpDataSourceFactory(userAgent, bandwidthMeter); } + public boolean useExtensionRenderers() { + return BuildConfig.FLAVOR.equals("withExtensions"); + } + } diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java index d79de04657..5ad28f9e72 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java @@ -27,8 +27,10 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.StreamingDrmSessionManager; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; import com.google.android.exoplayer2.metadata.id3.GeobFrame; import com.google.android.exoplayer2.metadata.id3.Id3Frame; import com.google.android.exoplayer2.metadata.id3.PrivFrame; @@ -38,15 +40,14 @@ import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelections; -import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.io.IOException; import java.text.NumberFormat; -import java.util.List; import java.util.Locale; /** @@ -55,7 +56,7 @@ import java.util.Locale; /* package */ final class EventLogger implements ExoPlayer.EventListener, AudioRendererEventListener, VideoRendererEventListener, AdaptiveMediaSourceEventListener, ExtractorMediaSource.EventListener, StreamingDrmSessionManager.EventListener, - TrackSelector.EventListener, MetadataRenderer.Output> { + MetadataRenderer.Output { private static final String TAG = "EventLogger"; private static final int MAX_TIMELINE_ITEM_LINES = 3; @@ -67,11 +68,13 @@ import java.util.Locale; TIME_FORMAT.setGroupingUsed(false); } + private final MappingTrackSelector trackSelector; private final Timeline.Window window; private final Timeline.Period period; private final long startTimeMs; - public EventLogger() { + public EventLogger(MappingTrackSelector trackSelector) { + this.trackSelector = trackSelector; window = new Timeline.Window(); period = new Timeline.Period(); startTimeMs = SystemClock.elapsedRealtime(); @@ -126,43 +129,57 @@ import java.util.Locale; Log.e(TAG, "playerFailed [" + getSessionTimeString() + "]", e); } - // MappingTrackSelector.EventListener - @Override - public void onTrackSelectionsChanged(TrackSelections trackSelections) { + public void onTracksChanged(TrackGroupArray ignored, TrackSelectionArray trackSelections) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + Log.d(TAG, "Tracks []"); + return; + } Log.d(TAG, "Tracks ["); // Log tracks associated to renderers. - MappedTrackInfo info = trackSelections.info; - for (int rendererIndex = 0; rendererIndex < trackSelections.length; rendererIndex++) { - TrackGroupArray trackGroups = info.getTrackGroups(rendererIndex); + for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) { + TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); TrackSelection trackSelection = trackSelections.get(rendererIndex); - if (trackGroups.length > 0) { + if (rendererTrackGroups.length > 0) { Log.d(TAG, " Renderer:" + rendererIndex + " ["); - for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { - TrackGroup trackGroup = trackGroups.get(groupIndex); - String adaptiveSupport = getAdaptiveSupportString( - trackGroup.length, info.getAdaptiveSupport(rendererIndex, groupIndex, false)); + for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { + TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); + String adaptiveSupport = getAdaptiveSupportString(trackGroup.length, + mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false)); Log.d(TAG, " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); String formatSupport = getFormatSupportString( - info.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); + mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); Log.d(TAG, " " + status + " Track:" + trackIndex + ", " + getFormatString(trackGroup.getFormat(trackIndex)) + ", supported=" + formatSupport); } Log.d(TAG, " ]"); } + // Log metadata for at most one of the tracks selected for the renderer. + if (trackSelection != null) { + for (int selectionIndex = 0; selectionIndex < trackSelection.length(); selectionIndex++) { + Metadata metadata = trackSelection.getFormat(selectionIndex).metadata; + if (metadata != null) { + Log.d(TAG, " Metadata ["); + printMetadata(metadata, " "); + Log.d(TAG, " ]"); + break; + } + } + } Log.d(TAG, " ]"); } } // Log tracks not associated with a renderer. - TrackGroupArray trackGroups = info.getUnassociatedTrackGroups(); - if (trackGroups.length > 0) { + TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups(); + if (unassociatedTrackGroups.length > 0) { Log.d(TAG, " Renderer:None ["); - for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { + for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { Log.d(TAG, " Group:" + groupIndex + " ["); - TrackGroup trackGroup = trackGroups.get(groupIndex); + TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { String status = getTrackStatusString(false); String formatSupport = getFormatSupportString( @@ -178,34 +195,13 @@ import java.util.Locale; Log.d(TAG, "]"); } - // MetadataRenderer.Output> + // MetadataRenderer.Output @Override - public void onMetadata(List id3Frames) { - for (Id3Frame id3Frame : id3Frames) { - if (id3Frame instanceof TxxxFrame) { - TxxxFrame txxxFrame = (TxxxFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: description=%s, value=%s", txxxFrame.id, - txxxFrame.description, txxxFrame.value)); - } else if (id3Frame instanceof PrivFrame) { - PrivFrame privFrame = (PrivFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: owner=%s", privFrame.id, privFrame.owner)); - } else if (id3Frame instanceof GeobFrame) { - GeobFrame geobFrame = (GeobFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, filename=%s, description=%s", - geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); - } else if (id3Frame instanceof ApicFrame) { - ApicFrame apicFrame = (ApicFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, description=%s", - apicFrame.id, apicFrame.mimeType, apicFrame.description)); - } else if (id3Frame instanceof TextInformationFrame) { - TextInformationFrame textInformationFrame = (TextInformationFrame) id3Frame; - Log.i(TAG, String.format("ID3 TimedMetadata %s: description=%s", textInformationFrame.id, - textInformationFrame.description)); - } else { - Log.i(TAG, String.format("ID3 TimedMetadata %s", id3Frame.id)); - } - } + public void onMetadata(Metadata metadata) { + Log.d(TAG, "onMetadata ["); + printMetadata(metadata, " "); + Log.d(TAG, "]"); } // AudioRendererEventListener @@ -350,6 +346,39 @@ import java.util.Locale; Log.e(TAG, "internalError [" + getSessionTimeString() + ", " + type + "]", e); } + private void printMetadata(Metadata metadata, String prefix) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TxxxFrame) { + TxxxFrame txxxFrame = (TxxxFrame) entry; + Log.d(TAG, prefix + String.format("%s: description=%s, value=%s", txxxFrame.id, + txxxFrame.description, txxxFrame.value)); + } else if (entry instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) entry; + Log.d(TAG, prefix + String.format("%s: owner=%s", privFrame.id, privFrame.owner)); + } else if (entry instanceof GeobFrame) { + GeobFrame geobFrame = (GeobFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, filename=%s, description=%s", + geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); + } else if (entry instanceof ApicFrame) { + ApicFrame apicFrame = (ApicFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, description=%s", + apicFrame.id, apicFrame.mimeType, apicFrame.description)); + } else if (entry instanceof TextInformationFrame) { + TextInformationFrame textInformationFrame = (TextInformationFrame) entry; + Log.d(TAG, prefix + String.format("%s: description=%s", textInformationFrame.id, + textInformationFrame.description)); + } else if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + Log.d(TAG, prefix + String.format("%s: language=%s description=%s", commentFrame.id, + commentFrame.language, commentFrame.description)); + } else if (entry instanceof Id3Frame) { + Id3Frame id3Frame = (Id3Frame) entry; + Log.d(TAG, prefix + String.format("%s", id3Frame.id)); + } + } + } + private String getSessionTimeString() { return getTimeString(SystemClock.elapsedRealtime() - startTimeMs); } diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index e9aa46f85f..243fcadce0 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -22,6 +22,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.text.TextUtils; +import android.view.KeyEvent; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; @@ -55,11 +56,9 @@ import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveVideoTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelections; -import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.DebugTextViewHelper; import com.google.android.exoplayer2.ui.PlaybackControlView; import com.google.android.exoplayer2.ui.SimpleExoPlayerView; @@ -78,7 +77,7 @@ import java.util.UUID; * An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends Activity implements OnClickListener, ExoPlayer.EventListener, - TrackSelector.EventListener, PlaybackControlView.VisibilityListener { + PlaybackControlView.VisibilityListener { public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid"; public static final String DRM_LICENSE_URL = "drm_license_url"; @@ -110,7 +109,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay private DataSource.Factory mediaDataSourceFactory; private SimpleExoPlayer player; - private MappingTrackSelector trackSelector; + private DefaultTrackSelector trackSelector; private TrackSelectionHelper trackSelectionHelper; private DebugTextViewHelper debugViewHelper; private boolean playerNeedsSource; @@ -196,6 +195,16 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } } + // Activity input + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Show the controls on any key event. + simpleExoPlayerView.showController(); + // If the event was not handled then see if the player view can handle it as a media key event. + return super.dispatchKeyEvent(event) || simpleExoPlayerView.dispatchMediaKeyEvent(event); + } + // OnClickListener methods @Override @@ -203,8 +212,11 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay if (view == retryButton) { initializePlayer(); } else if (view.getParent() == debugRootView) { - trackSelectionHelper.showSelectionDialog(this, ((Button) view).getText(), - trackSelector.getCurrentSelections().info, (int) view.getTag()); + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo != null) { + trackSelectionHelper.showSelectionDialog(this, ((Button) view).getText(), + trackSelector.getCurrentMappedTrackInfo(), (int) view.getTag()); + } } } @@ -249,20 +261,25 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } } - eventLogger = new EventLogger(); + @SimpleExoPlayer.ExtensionRendererMode int extensionRendererMode = + ((DemoApplication) getApplication()).useExtensionRenderers() + ? (preferExtensionDecoders ? SimpleExoPlayer.EXTENSION_RENDERER_MODE_PREFER + : SimpleExoPlayer.EXTENSION_RENDERER_MODE_ON) + : SimpleExoPlayer.EXTENSION_RENDERER_MODE_OFF; TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveVideoTrackSelection.Factory(BANDWIDTH_METER); - trackSelector = new DefaultTrackSelector(mainHandler, videoTrackSelectionFactory); - trackSelector.addListener(this); - trackSelector.addListener(eventLogger); + trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); trackSelectionHelper = new TrackSelectionHelper(trackSelector, videoTrackSelectionFactory); player = ExoPlayerFactory.newSimpleInstance(this, trackSelector, new DefaultLoadControl(), - drmSessionManager, preferExtensionDecoders); + drmSessionManager, extensionRendererMode); player.addListener(this); + + eventLogger = new EventLogger(trackSelector); player.addListener(eventLogger); player.setAudioDebugListener(eventLogger); player.setVideoDebugListener(eventLogger); player.setId3Output(eventLogger); + simpleExoPlayerView.setPlayer(player); if (isTimelineStatic) { if (playerPosition == C.TIME_UNSET) { @@ -353,7 +370,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay playerWindow = player.getCurrentWindowIndex(); playerPosition = C.TIME_UNSET; Timeline timeline = player.getCurrentTimeline(); - if (timeline != null && timeline.getWindow(playerWindow, window).isSeekable) { + if (!timeline.isEmpty() && timeline.getWindow(playerWindow, window).isSeekable) { playerPosition = player.getCurrentPosition(); } player.release(); @@ -410,7 +427,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay @Override public void onTimelineChanged(Timeline timeline, Object manifest) { - isTimelineStatic = timeline != null && timeline.getWindowCount() > 0 + isTimelineStatic = !timeline.isEmpty() && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic; } @@ -447,17 +464,19 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay showControls(); } - // MappingTrackSelector.EventListener implementation - @Override - public void onTrackSelectionsChanged(TrackSelections trackSelections) { + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { updateButtonVisibilities(); - MappedTrackInfo trackInfo = trackSelections.info; - if (trackInfo.hasOnlyUnplayableTracks(C.TRACK_TYPE_VIDEO)) { - showToast(R.string.error_unsupported_video); - } - if (trackInfo.hasOnlyUnplayableTracks(C.TRACK_TYPE_AUDIO)) { - showToast(R.string.error_unsupported_audio); + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo != null) { + if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_VIDEO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_video); + } + if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_AUDIO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_audio); + } } } @@ -473,14 +492,13 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay return; } - TrackSelections trackSelections = trackSelector.getCurrentSelections(); - if (trackSelections == null) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { return; } - int rendererCount = trackSelections.length; - for (int i = 0; i < rendererCount; i++) { - TrackGroupArray trackGroups = trackSelections.info.getTrackGroups(i); + for (int i = 0; i < mappedTrackInfo.length; i++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(i); if (trackGroups.length != 0) { Button button = new Button(this); int label; diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 95d42e0532..946181284f 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.demo; import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.content.res.AssetManager; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; @@ -43,6 +44,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -63,9 +65,21 @@ public class SampleChooserActivity extends Activity { if (dataUri != null) { uris = new String[] {dataUri}; } else { - uris = new String[] { - "asset:///media.exolist.json", - }; + ArrayList uriList = new ArrayList<>(); + AssetManager assetManager = getAssets(); + try { + for (String asset : assetManager.list("")) { + if (asset.endsWith(".exolist.json")) { + uriList.add("asset:///" + asset); + } + } + } catch (IOException e) { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + } + uris = new String[uriList.size()]; + uriList.toArray(uris); + Arrays.sort(uris); } SampleListLoader loaderTask = new SampleListLoader(); loaderTask.execute(uris); diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java b/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java index 8892c138d0..936cdf90f8 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java +++ b/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java @@ -18,7 +18,9 @@ package com.google.android.exoplayer2.demo; import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; +import android.content.Context; import android.content.DialogInterface; +import android.content.res.TypedArray; import android.text.TextUtils; import android.util.Pair; import android.view.LayoutInflater; @@ -100,7 +102,7 @@ import java.util.Locale; AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(title) - .setView(buildView(LayoutInflater.from(builder.getContext()))) + .setView(buildView(builder.getContext())) .setPositiveButton(android.R.string.ok, this) .setNegativeButton(android.R.string.cancel, null) .create() @@ -108,13 +110,20 @@ import java.util.Locale; } @SuppressLint("InflateParams") - private View buildView(LayoutInflater inflater) { + private View buildView(Context context) { + LayoutInflater inflater = LayoutInflater.from(context); View view = inflater.inflate(R.layout.track_selection_dialog, null); ViewGroup root = (ViewGroup) view.findViewById(R.id.root); + TypedArray attributeArray = context.getTheme().obtainStyledAttributes( + new int[] {android.R.attr.selectableItemBackground}); + int selectableItemBackgroundResourceId = attributeArray.getResourceId(0, 0); + attributeArray.recycle(); + // View for disabling the renderer. disableView = (CheckedTextView) inflater.inflate( android.R.layout.simple_list_item_single_choice, root, false); + disableView.setBackgroundResource(selectableItemBackgroundResourceId); disableView.setText(R.string.selection_disabled); disableView.setFocusable(true); disableView.setOnClickListener(this); @@ -123,6 +132,7 @@ import java.util.Locale; // View for clearing the override to allow the selector to use its default selection logic. defaultView = (CheckedTextView) inflater.inflate( android.R.layout.simple_list_item_single_choice, root, false); + defaultView.setBackgroundResource(selectableItemBackgroundResourceId); defaultView.setText(R.string.selection_default); defaultView.setFocusable(true); defaultView.setOnClickListener(this); @@ -146,6 +156,7 @@ import java.util.Locale; : android.R.layout.simple_list_item_single_choice; CheckedTextView trackView = (CheckedTextView) inflater.inflate( trackViewLayoutId, root, false); + trackView.setBackgroundResource(selectableItemBackgroundResourceId); trackView.setText(buildTrackName(group.getFormat(trackIndex))); if (trackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex) == RendererCapabilities.FORMAT_HANDLED) { @@ -169,6 +180,7 @@ import java.util.Locale; // View for using random adaptation. enableRandomAdaptationView = (CheckedTextView) inflater.inflate( android.R.layout.simple_list_item_multiple_choice, root, false); + enableRandomAdaptationView.setBackgroundResource(selectableItemBackgroundResourceId); enableRandomAdaptationView.setText(R.string.enable_random_adaptation); enableRandomAdaptationView.setOnClickListener(this); root.addView(inflater.inflate(R.layout.list_divider, root, false)); diff --git a/demo/src/main/res/drawable-xhdpi/ic_banner.png b/demo/src/main/res/drawable-xhdpi/ic_banner.png new file mode 100644 index 0000000000..dad9daa4de Binary files /dev/null and b/demo/src/main/res/drawable-xhdpi/ic_banner.png differ diff --git a/demo/src/main/res/layout/player_activity.xml b/demo/src/main/res/layout/player_activity.xml index 07ac5e2ba1..3f8cdaa7d6 100644 --- a/demo/src/main/res/layout/player_activity.xml +++ b/demo/src/main/res/layout/player_activity.xml @@ -16,13 +16,11 @@ diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md index be79ac7d3e..a570385a52 100644 --- a/extensions/cronet/README.md +++ b/extensions/cronet/README.md @@ -21,8 +21,9 @@ git clone https://github.com/google/ExoPlayer.git 1. Find the latest Cronet release [here][] and navigate to its `Release/cronet` directory -1. Download `cronet.jar`, `cronet_api.jar` and the `libs` directory -1. Copy the two jar files into the `libs` directory of this extension +1. Download `cronet_api.jar`, `cronet_impl_common_java.jar`, + `cronet_impl_native_java.jar` and the `libs` directory +1. Copy the three jar files into the `libs` directory of this extension 1. Copy the content of the downloaded `libs` directory into the `jniLibs` directory of this extension diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index ae2914dba3..3c9a36c891 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -42,10 +42,11 @@ android { dependencies { compile project(':library') compile files('libs/cronet_api.jar') - compile files('libs/cronet.jar') + compile files('libs/cronet_impl_common_java.jar') + compile files('libs/cronet_impl_native_java.jar') androidTestCompile 'com.google.dexmaker:dexmaker:1.2' androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.2' androidTestCompile 'org.mockito:mockito-core:1.9.5' androidTestCompile project(':library') - androidTestCompile 'com.android.support.test:runner:0.4' + androidTestCompile 'com.android.support.test:runner:0.5' } diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java index b0de0784de..7efc542dd0 100644 --- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -22,7 +22,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; @@ -52,7 +51,6 @@ import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -62,6 +60,7 @@ import org.chromium.net.CronetEngine; import org.chromium.net.UrlRequest; import org.chromium.net.UrlRequestException; import org.chromium.net.UrlResponseInfo; +import org.chromium.net.impl.UrlResponseInfoImpl; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -88,20 +87,7 @@ public final class CronetDataSourceTest { private Map testResponseHeader; private UrlResponseInfo testUrlResponseInfo; - /** - * MockableCronetEngine is an abstract class for helping creating new Requests. - */ - public abstract static class MockableCronetEngine extends CronetEngine { - - @Override - public abstract UrlRequest createRequest(String url, UrlRequest.Callback callback, - Executor executor, int priority, - Collection connectionAnnotations, - boolean disableCache, - boolean disableConnectionMigration, - boolean allowDirectExecutor); - } - + @Mock private UrlRequest.Builder mockUrlRequestBuilder; @Mock private UrlRequest mockUrlRequest; @Mock @@ -114,8 +100,7 @@ public final class CronetDataSourceTest { private Executor mockExecutor; @Mock private UrlRequestException mockUrlRequestException; - @Mock - private MockableCronetEngine mockCronetEngine; + @Mock private CronetEngine mockCronetEngine; private CronetDataSource dataSourceUnderTest; @@ -135,15 +120,10 @@ public final class CronetDataSourceTest { true, // resetTimeoutOnRedirects mockClock)); when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true); - when(mockCronetEngine.createRequest( - anyString(), - any(UrlRequest.Callback.class), - any(Executor.class), - anyInt(), - eq(Collections.emptyList()), - any(Boolean.class), - any(Boolean.class), - any(Boolean.class))).thenReturn(mockUrlRequest); + when(mockCronetEngine.newUrlRequestBuilder( + anyString(), any(UrlRequest.Callback.class), any(Executor.class))) + .thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest); mockStatusResponse(); testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null); @@ -159,7 +139,7 @@ public final class CronetDataSourceTest { private UrlResponseInfo createUrlResponseInfo(int statusCode) { ArrayList> responseHeaderList = new ArrayList<>(); responseHeaderList.addAll(testResponseHeader.entrySet()); - return new UrlResponseInfo( + return new UrlResponseInfoImpl( Collections.singletonList(TEST_URL), statusCode, null, // httpStatusText @@ -184,15 +164,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.close(); // Prepare a mock UrlRequest to be used in the second open() call. final UrlRequest mockUrlRequest2 = mock(UrlRequest.class); - when(mockCronetEngine.createRequest( - anyString(), - any(UrlRequest.Callback.class), - any(Executor.class), - anyInt(), - eq(Collections.emptyList()), - any(Boolean.class), - any(Boolean.class), - any(Boolean.class))).thenReturn(mockUrlRequest2); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2); doAnswer(new Answer() { @Override public Object answer(InvocationOnMock invocation) throws Throwable { @@ -215,15 +187,8 @@ public final class CronetDataSourceTest { mockResponseStartSuccess(); dataSourceUnderTest.open(testDataSpec); - verify(mockCronetEngine).createRequest( - eq(TEST_URL), - any(UrlRequest.Callback.class), - any(Executor.class), - anyInt(), - eq(Collections.emptyList()), - any(Boolean.class), - any(Boolean.class), - any(Boolean.class)); + verify(mockCronetEngine) + .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class)); verify(mockUrlRequest).start(); } @@ -237,9 +202,9 @@ public final class CronetDataSourceTest { dataSourceUnderTest.open(testDataSpec); // The header value to add is current position to current position + length - 1. - verify(mockUrlRequest).addHeader("Range", "bytes=1000-5999"); - verify(mockUrlRequest).addHeader("firstHeader", "firstValue"); - verify(mockUrlRequest).addHeader("secondHeader", "secondValue"); + verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999"); + verify(mockUrlRequestBuilder).addHeader("firstHeader", "firstValue"); + verify(mockUrlRequestBuilder).addHeader("secondHeader", "secondValue"); verify(mockUrlRequest).start(); } diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 0190668a70..83f46bd488 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -412,8 +412,8 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou // Internal methods. private UrlRequest buildRequest(DataSpec dataSpec) throws OpenException { - UrlRequest.Builder requestBuilder = new UrlRequest.Builder(dataSpec.uri.toString(), this, - executor, cronetEngine); + UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder(dataSpec.uri.toString(), + this, executor); // Set the headers. synchronized (requestProperties) { if (dataSpec.postBody != null && dataSpec.postBody.length != 0 diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md index f0ce07bdf7..d7c5e21fcc 100644 --- a/extensions/ffmpeg/README.md +++ b/extensions/ffmpeg/README.md @@ -31,7 +31,7 @@ FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main" NDK_PATH="" ``` -* Fetch and build ffmpeg. +* Fetch and build FFmpeg. For example, to fetch and build for armv7a: @@ -75,7 +75,7 @@ cd "${FFMPEG_EXT_PATH}"/jni && \ ${NDK_PATH}/ndk-build APP_ABI=armeabi-v7a -j4 ``` -TODO: Add instructions for other ABIs. +Repeat these steps for any other architectures you need to support. * In your project, you can add a dependency on the extension by using a rule like this: diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index 838ed1c3e9..1a70310a8d 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -20,8 +20,8 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.audio.AudioTrack; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; /** @@ -53,11 +53,10 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioCapabilities The audio capabilities for playback on this device. May be null if the * default capabilities (no encoded audio passthrough support) should be assumed. - * @param streamType The type of audio stream for the {@link AudioTrack}. */ public FfmpegAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, - AudioCapabilities audioCapabilities, int streamType) { - super(eventHandler, eventListener, audioCapabilities, streamType); + AudioCapabilities audioCapabilities) { + super(eventHandler, eventListener, audioCapabilities); } @Override @@ -71,7 +70,8 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - protected FfmpegDecoder createDecoder(Format format) throws FfmpegDecoderException { + protected FfmpegDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) + throws FfmpegDecoderException { decoder = new FfmpegDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE, format.sampleMimeType, format.initializationData); return decoder; diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc index 0d083a8bd4..fa615f2ec1 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -267,7 +267,7 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, sampleFormat, 1); AVAudioResampleContext *resampleContext; if (context->opaque) { - resampleContext = (AVAudioResampleContext *)context->opaque; + resampleContext = (AVAudioResampleContext *) context->opaque; } else { resampleContext = avresample_alloc_context(); av_opt_set_int(resampleContext, "in_channel_layout", channelLayout, 0); @@ -326,7 +326,7 @@ void releaseContext(AVCodecContext *context) { return; } AVAudioResampleContext *resampleContext; - if (resampleContext = (AVAudioResampleContext *)context->opaque) { + if ((resampleContext = (AVAudioResampleContext *) context->opaque)) { avresample_free(&resampleContext); context->opaque = NULL; } diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index 29a22f380a..990c470a93 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.ext.flac; import android.content.Context; import android.net.Uri; -import android.os.Handler; import android.os.Looper; import android.test.InstrumentationTestCase; import com.google.android.exoplayer2.ExoPlaybackException; @@ -27,7 +26,9 @@ import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; /** @@ -72,7 +73,7 @@ public class FlacPlaybackTest extends InstrumentationTestCase { public void run() { Looper.prepare(); LibflacAudioRenderer audioRenderer = new LibflacAudioRenderer(); - DefaultTrackSelector trackSelector = new DefaultTrackSelector(new Handler()); + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); player = ExoPlayerFactory.newInstance(new Renderer[] {audioRenderer}, trackSelector); player.addListener(this); ExtractorMediaSource mediaSource = new ExtractorMediaSource( @@ -91,6 +92,11 @@ public class FlacPlaybackTest extends InstrumentationTestCase { // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do nothing. + } + @Override public void onPositionDiscontinuity() { // Do nothing. diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index fd1bb16a4b..42c5908619 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -95,7 +95,7 @@ public final class FlacExtractor implements Extractor { if (streamInfo == null) { throw new IOException("Metadata decoding failed"); } - } catch (IOException e){ + } catch (IOException e) { decoderJni.reset(0); input.setRetryPosition(0, e); throw e; // never executes @@ -137,7 +137,7 @@ public final class FlacExtractor implements Extractor { int size; try { size = decoderJni.decodeSample(outputByteBuffer); - } catch (IOException e){ + } catch (IOException e) { if (lastDecodePosition >= 0) { decoderJni.reset(lastDecodePosition); input.setRetryPosition(lastDecodePosition, e); @@ -155,7 +155,7 @@ public final class FlacExtractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { if (position == 0) { metadataParsed = false; } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 931b5ff3d9..954a090ee9 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -19,8 +19,8 @@ import android.os.Handler; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.audio.AudioTrack; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; /** @@ -49,11 +49,10 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioCapabilities The audio capabilities for playback on this device. May be null if the * default capabilities (no encoded audio passthrough support) should be assumed. - * @param streamType The type of audio stream for the {@link AudioTrack}. */ public LibflacAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, - AudioCapabilities audioCapabilities, int streamType) { - super(eventHandler, eventListener, audioCapabilities, streamType); + AudioCapabilities audioCapabilities) { + super(eventHandler, eventListener, audioCapabilities); } @Override @@ -63,7 +62,8 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - protected FlacDecoder createDecoder(Format format) throws FlacDecoderException { + protected FlacDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) + throws FlacDecoderException { return new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.initializationData); } diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index c7555e9ced..442f0f78dc 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -37,7 +37,7 @@ android { dependencies { compile project(':library') - compile('com.squareup.okhttp3:okhttp:+') { + compile('com.squareup.okhttp3:okhttp:3.4.1') { exclude group: 'org.json' } } diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java index 2b6eaa736d..8577d33781 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java @@ -65,7 +65,8 @@ public class OkHttpDataSource implements HttpDataSource { private long bytesRead; /** - * @param callFactory An {@link Call.Factory} for use by the source. + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the source. * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a InvalidContentTypeException} is thrown from {@link #open(DataSpec)}. @@ -76,7 +77,8 @@ public class OkHttpDataSource implements HttpDataSource { } /** - * @param callFactory An {@link Call.Factory} for use by the source. + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the source. * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link InvalidContentTypeException} is thrown from @@ -89,14 +91,14 @@ public class OkHttpDataSource implements HttpDataSource { } /** - * @param callFactory An {@link Call.Factory} for use by the source. + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the source. * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link InvalidContentTypeException} is thrown from * {@link #open(DataSpec)}. * @param listener An optional listener. - * @param cacheControl An optional {@link CacheControl} which sets all requests' Cache-Control - * header. For example, you could force the network response for all requests. + * @param cacheControl An optional {@link CacheControl} for setting the Cache-Control header. */ public OkHttpDataSource(Call.Factory callFactory, String userAgent, Predicate contentTypePredicate, TransferListener listener, diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java index a4dd10a8d3..33f204a6f3 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java @@ -28,25 +28,38 @@ public final class OkHttpDataSourceFactory implements Factory { private final Call.Factory callFactory; private final String userAgent; - private final TransferListener transferListener; + private final TransferListener listener; private final CacheControl cacheControl; + /** + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the sources created by the factory. + * @param userAgent The User-Agent string that should be used. + * @param listener An optional listener. + */ public OkHttpDataSourceFactory(Call.Factory callFactory, String userAgent, - TransferListener transferListener) { - this(callFactory, userAgent, transferListener, null); + TransferListener listener) { + this(callFactory, userAgent, listener, null); } + /** + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the sources created by the factory. + * @param userAgent The User-Agent string that should be used. + * @param listener An optional listener. + * @param cacheControl An optional {@link CacheControl} for setting the Cache-Control header. + */ public OkHttpDataSourceFactory(Call.Factory callFactory, String userAgent, - TransferListener transferListener, CacheControl cacheControl) { + TransferListener listener, CacheControl cacheControl) { this.callFactory = callFactory; this.userAgent = userAgent; - this.transferListener = transferListener; + this.listener = listener; this.cacheControl = cacheControl; } @Override public OkHttpDataSource createDataSource() { - return new OkHttpDataSource(callFactory, userAgent, null, transferListener, cacheControl); + return new OkHttpDataSource(callFactory, userAgent, null, listener, cacheControl); } } diff --git a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java index 4f723698a4..3e07186995 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.ext.opus; import android.content.Context; import android.net.Uri; -import android.os.Handler; import android.os.Looper; import android.test.InstrumentationTestCase; import com.google.android.exoplayer2.ExoPlaybackException; @@ -27,7 +26,9 @@ import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; /** @@ -72,7 +73,7 @@ public class OpusPlaybackTest extends InstrumentationTestCase { public void run() { Looper.prepare(); LibopusAudioRenderer audioRenderer = new LibopusAudioRenderer(); - DefaultTrackSelector trackSelector = new DefaultTrackSelector(new Handler()); + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); player = ExoPlayerFactory.newInstance(new Renderer[] {audioRenderer}, trackSelector); player.addListener(this); ExtractorMediaSource mediaSource = new ExtractorMediaSource( @@ -91,6 +92,11 @@ public class OpusPlaybackTest extends InstrumentationTestCase { // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do nothing. + } + @Override public void onPositionDiscontinuity() { // Do nothing. diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index 3393562104..2dd2697aab 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -19,8 +19,9 @@ import android.os.Handler; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.audio.AudioTrack; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; /** @@ -50,11 +51,24 @@ public final class LibopusAudioRenderer extends SimpleDecoderAudioRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioCapabilities The audio capabilities for playback on this device. May be null if the * default capabilities (no encoded audio passthrough support) should be assumed. - * @param streamType The type of audio stream for the {@link AudioTrack}. */ public LibopusAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, - AudioCapabilities audioCapabilities, int streamType) { - super(eventHandler, eventListener, audioCapabilities, streamType); + AudioCapabilities audioCapabilities) { + super(eventHandler, eventListener, audioCapabilities); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + */ + public LibopusAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, + AudioCapabilities audioCapabilities, DrmSessionManager drmSessionManager, + boolean playClearSamplesWithoutKeys) { + super(eventHandler, eventListener, audioCapabilities, drmSessionManager, + playClearSamplesWithoutKeys); } @Override @@ -64,9 +78,10 @@ public final class LibopusAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - protected OpusDecoder createDecoder(Format format) throws OpusDecoderException { + protected OpusDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) + throws OpusDecoderException { return new OpusDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE, - format.initializationData); + format.initializationData, mediaCrypto); } } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java index 73fb4072e8..6d0deb44ae 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java @@ -16,9 +16,12 @@ package com.google.android.exoplayer2.ext.opus; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.drm.DecryptionException; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.List; @@ -36,6 +39,12 @@ import java.util.List; */ private static final int SAMPLE_RATE = 48000; + private static final int NO_ERROR = 0; + private static final int DECODE_ERROR = -1; + private static final int DRM_ERROR = -2; + + private final ExoMediaCrypto exoMediaCrypto; + private final int channelCount; private final int headerSkipSamples; private final int headerSeekPreRollSamples; @@ -52,14 +61,20 @@ import java.util.List; * @param initializationData Codec-specific initialization data. The first element must contain an * opus header. Optionally, the list may contain two additional buffers, which must contain * the encoder delay and seek pre roll values in nanoseconds, encoded as longs. + * @param exoMediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted + * content. Maybe null and can be ignored if decoder does not handle encrypted content. * @throws OpusDecoderException Thrown if an exception occurs when initializing the decoder. */ public OpusDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, - List initializationData) throws OpusDecoderException { + List initializationData, ExoMediaCrypto exoMediaCrypto) throws OpusDecoderException { super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]); if (!OpusLibrary.isAvailable()) { throw new OpusDecoderException("Failed to load decoder native libraries."); } + this.exoMediaCrypto = exoMediaCrypto; + if (exoMediaCrypto != null && !OpusLibrary.opusIsSecureDecodeSupported()) { + throw new OpusDecoderException("Opus decoder does not support secure decode."); + } byte[] headerBytes = initializationData.get(0); if (headerBytes.length < 19) { throw new OpusDecoderException("Header size is too small."); @@ -139,11 +154,25 @@ import java.util.List; skipSamples = (inputBuffer.timeUs == 0) ? headerSkipSamples : headerSeekPreRollSamples; } ByteBuffer inputData = inputBuffer.data; - int result = opusDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), - outputBuffer, SAMPLE_RATE); + CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; + int result = inputBuffer.isEncrypted() + ? opusSecureDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), + outputBuffer, SAMPLE_RATE, exoMediaCrypto, cryptoInfo.mode, + cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) + : opusDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), + outputBuffer, SAMPLE_RATE); if (result < 0) { - return new OpusDecoderException("Decode error: " + opusGetErrorMessage(result)); + if (result == DRM_ERROR) { + String message = "Drm error: " + opusGetErrorMessage(nativeDecoderContext); + DecryptionException cause = new DecryptionException( + opusGetErrorCode(nativeDecoderContext), message); + return new OpusDecoderException(message, cause); + } else { + return new OpusDecoderException("Decode error: " + opusGetErrorMessage(result)); + } } + ByteBuffer outputData = outputBuffer.data; outputData.position(0); outputData.limit(result); @@ -182,8 +211,13 @@ import java.util.List; int gain, byte[] streamMap); private native int opusDecode(long decoder, long timeUs, ByteBuffer inputBuffer, int inputSize, SimpleOutputBuffer outputBuffer, int sampleRate); + private native int opusSecureDecode(long decoder, long timeUs, ByteBuffer inputBuffer, + int inputSize, SimpleOutputBuffer outputBuffer, int sampleRate, + ExoMediaCrypto wvCrypto, int inputMode, byte[] key, byte[] iv, + int numSubSamples, int[] numBytesOfClearData, int[] numBytesOfEncryptedData); private native void opusClose(long decoder); private native void opusReset(long decoder); - private native String opusGetErrorMessage(int errorCode); + private native int opusGetErrorCode(long decoder); + private native String opusGetErrorMessage(long decoder); } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoderException.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoderException.java index 338f3ea94e..6645086838 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoderException.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoderException.java @@ -26,4 +26,8 @@ public final class OpusDecoderException extends AudioDecoderException { super(message); } + /* package */ OpusDecoderException(String message, Throwable cause) { + super(message, cause); + } + } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java index a79ef6df3a..41a28b9fd7 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java @@ -50,5 +50,5 @@ public final class OpusLibrary { } public static native String opusGetVersion(); - + public static native boolean opusIsSecureDecodeSupported(); } diff --git a/extensions/opus/src/main/jni/opus_jni.cc b/extensions/opus/src/main/jni/opus_jni.cc index 0920d9e499..48c1bd5e6d 100644 --- a/extensions/opus/src/main/jni/opus_jni.cc +++ b/extensions/opus/src/main/jni/opus_jni.cc @@ -60,11 +60,13 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { static const int kBytesPerSample = 2; // opus fixed point uses 16 bit samples. static int channelCount; +static int errorCode; DECODER_FUNC(jlong, opusInit, jint sampleRate, jint channelCount, jint numStreams, jint numCoupled, jint gain, jbyteArray jStreamMap) { int status = OPUS_INVALID_STATE; ::channelCount = channelCount; + errorCode = 0; jbyte* streamMapBytes = env->GetByteArrayElements(jStreamMap, 0); uint8_t* streamMap = reinterpret_cast(streamMapBytes); OpusMSDecoder* decoder = opus_multistream_decoder_create( @@ -109,10 +111,24 @@ DECODER_FUNC(jint, opusDecode, jlong jDecoder, jlong jTimeUs, env->GetDirectBufferAddress(jOutputBufferData)); int sampleCount = opus_multistream_decode(decoder, inputBuffer, inputSize, outputBufferData, outputSize, 0); + // record error code + errorCode = (sampleCount < 0) ? sampleCount : 0; return (sampleCount < 0) ? sampleCount : sampleCount * kBytesPerSample * channelCount; } +DECODER_FUNC(jint, opusSecureDecode, jlong jDecoder, jlong jTimeUs, + jobject jInputBuffer, jint inputSize, jobject jOutputBuffer, + jint sampleRate, jobject mediaCrypto, jint inputMode, jbyteArray key, + jbyteArray javaIv, jint inputNumSubSamples, jintArray numBytesOfClearData, + jintArray numBytesOfEncryptedData) { + // Doesn't support + // Java client should have checked vpxSupportSecureDecode + // and avoid calling this + // return -2 (DRM Error) + return -2; +} + DECODER_FUNC(void, opusClose, jlong jDecoder) { OpusMSDecoder* decoder = reinterpret_cast(jDecoder); opus_multistream_decoder_destroy(decoder); @@ -123,10 +139,19 @@ DECODER_FUNC(void, opusReset, jlong jDecoder) { opus_multistream_decoder_ctl(decoder, OPUS_RESET_STATE); } -DECODER_FUNC(jstring, opusGetErrorMessage, jint errorCode) { +DECODER_FUNC(jstring, opusGetErrorMessage, jlong jContext) { return env->NewStringUTF(opus_strerror(errorCode)); } +DECODER_FUNC(jint, opusGetErrorCode, jlong jContext) { + return errorCode; +} + +LIBRARY_FUNC(jstring, opusIsSecureDecodeSupported) { + // Doesn't support + return 0; +} + LIBRARY_FUNC(jstring, opusGetVersion) { return env->NewStringUTF(opus_get_version_string()); } diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index c5f61cf231..b1ddf2368c 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.ext.vp9; import android.content.Context; import android.net.Uri; -import android.os.Handler; import android.os.Looper; import android.test.InstrumentationTestCase; import com.google.android.exoplayer2.ExoPlaybackException; @@ -27,7 +26,9 @@ import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; /** @@ -88,7 +89,7 @@ public class VpxPlaybackTest extends InstrumentationTestCase { public void run() { Looper.prepare(); LibvpxVideoRenderer videoRenderer = new LibvpxVideoRenderer(true, 0); - DefaultTrackSelector trackSelector = new DefaultTrackSelector(new Handler()); + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); player = ExoPlayerFactory.newInstance(new Renderer[] {videoRenderer}, trackSelector); player.addListener(this); ExtractorMediaSource mediaSource = new ExtractorMediaSource( @@ -110,6 +111,11 @@ public class VpxPlaybackTest extends InstrumentationTestCase { // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do nothing. + } + @Override public void onPositionDiscontinuity() { // Do nothing. diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java index cfdd962197..e4cc2ae3ce 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.ext.vp9; import android.graphics.Bitmap; import android.graphics.Canvas; import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; import android.view.Surface; import com.google.android.exoplayer2.BaseRenderer; @@ -28,8 +29,13 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TraceUtil; +import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoRendererEventListener; import com.google.android.exoplayer2.video.VideoRendererEventListener.EventDispatcher; @@ -56,8 +62,10 @@ public final class LibvpxVideoRenderer extends BaseRenderer { private final boolean scaleToFit; private final long allowedJoiningTimeMs; private final int maxDroppedFramesToNotify; + private final boolean playClearSamplesWithoutKeys; private final EventDispatcher eventDispatcher; private final FormatHolder formatHolder; + private final DrmSessionManager drmSessionManager; private DecoderCounters decoderCounters; private Format format; @@ -65,6 +73,8 @@ public final class LibvpxVideoRenderer extends BaseRenderer { private DecoderInputBuffer inputBuffer; private VpxOutputBuffer outputBuffer; private VpxOutputBuffer nextOutputBuffer; + private DrmSession drmSession; + private DrmSession pendingDrmSession; private Bitmap bitmap; private boolean renderedFirstFrame; @@ -72,11 +82,12 @@ public final class LibvpxVideoRenderer extends BaseRenderer { private Surface surface; private VpxOutputBufferRenderer outputBufferRenderer; private int outputMode; + private boolean waitingForKeys; private boolean inputStreamEnded; private boolean outputStreamEnded; - private int previousWidth; - private int previousHeight; + private int lastReportedWidth; + private int lastReportedHeight; private long droppedFrameAccumulationStartTimeMs; private int droppedFrames; @@ -104,13 +115,39 @@ public final class LibvpxVideoRenderer extends BaseRenderer { public LibvpxVideoRenderer(boolean scaleToFit, long allowedJoiningTimeMs, Handler eventHandler, VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { + this(scaleToFit, allowedJoiningTimeMs, eventHandler, eventListener, maxDroppedFramesToNotify, + null, false); + } + + /** + * @param scaleToFit Whether video frames should be scaled to fit when rendering. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + */ + public LibvpxVideoRenderer(boolean scaleToFit, long allowedJoiningTimeMs, + Handler eventHandler, VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify, DrmSessionManager drmSessionManager, + boolean playClearSamplesWithoutKeys) { super(C.TRACK_TYPE_VIDEO); this.scaleToFit = scaleToFit; this.allowedJoiningTimeMs = allowedJoiningTimeMs; this.maxDroppedFramesToNotify = maxDroppedFramesToNotify; + this.drmSessionManager = drmSessionManager; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; joiningDeadlineMs = -1; - previousWidth = -1; - previousHeight = -1; + clearLastReportedVideoSize(); formatHolder = new FormatHolder(); eventDispatcher = new EventDispatcher(eventHandler, eventListener); outputMode = VpxDecoder.OUTPUT_MODE_NONE; @@ -135,12 +172,27 @@ public final class LibvpxVideoRenderer extends BaseRenderer { } if (isRendererAvailable()) { + drmSession = pendingDrmSession; + ExoMediaCrypto mediaCrypto = null; + if (drmSession != null) { + int drmSessionState = drmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + } else if (drmSessionState == DrmSession.STATE_OPENED + || drmSessionState == DrmSession.STATE_OPENED_WITH_KEYS) { + mediaCrypto = drmSession.getMediaCrypto(); + } else { + // The drm session isn't open yet. + return; + } + } try { if (decoder == null) { // If we don't have a decoder yet, we need to instantiate one. long codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createVpxDecoder"); - decoder = new VpxDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE); + decoder = new VpxDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE, + mediaCrypto); decoder.setOutputMode(outputMode); TraceUtil.endSection(); long codecInitializedTimestamp = SystemClock.elapsedRealtime(); @@ -258,7 +310,7 @@ public final class LibvpxVideoRenderer extends BaseRenderer { surface.unlockCanvasAndPost(canvas); } - private boolean feedInputBuffer() throws VpxDecoderException { + private boolean feedInputBuffer() throws VpxDecoderException, ExoPlaybackException { if (inputStreamEnded) { return false; } @@ -270,7 +322,14 @@ public final class LibvpxVideoRenderer extends BaseRenderer { } } - int result = readSource(formatHolder, inputBuffer); + int result; + if (waitingForKeys) { + // We've already read an encrypted sample into buffer, and are waiting for keys. + result = C.RESULT_BUFFER_READ; + } else { + result = readSource(formatHolder, inputBuffer); + } + if (result == C.RESULT_NOTHING_READ) { return false; } @@ -284,6 +343,11 @@ public final class LibvpxVideoRenderer extends BaseRenderer { inputBuffer = null; return false; } + boolean bufferEncrypted = inputBuffer.isEncrypted(); + waitingForKeys = shouldWaitForKeys(bufferEncrypted); + if (waitingForKeys) { + return false; + } inputBuffer.flip(); decoder.queueInputBuffer(inputBuffer); decoderCounters.inputBufferCount++; @@ -291,8 +355,21 @@ public final class LibvpxVideoRenderer extends BaseRenderer { return true; } + private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { + if (drmSession == null) { + return false; + } + int drmSessionState = drmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + } + return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS + && (bufferEncrypted || !playClearSamplesWithoutKeys); + } + private void flushDecoder() { inputBuffer = null; + waitingForKeys = false; if (outputBuffer != null) { outputBuffer.release(); outputBuffer = null; @@ -311,6 +388,9 @@ public final class LibvpxVideoRenderer extends BaseRenderer { @Override public boolean isReady() { + if (waitingForKeys) { + return false; + } if (format != null && (isSourceReady() || outputBuffer != null) && (renderedFirstFrame || !isRendererAvailable())) { // Ready. If we were joining then we've now joined, so clear the joining deadline. @@ -365,11 +445,27 @@ public final class LibvpxVideoRenderer extends BaseRenderer { inputBuffer = null; outputBuffer = null; format = null; + waitingForKeys = false; + clearLastReportedVideoSize(); try { releaseDecoder(); } finally { - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); + try { + if (drmSession != null) { + drmSessionManager.releaseSession(drmSession); + } + } finally { + try { + if (pendingDrmSession != null && pendingDrmSession != drmSession) { + drmSessionManager.releaseSession(pendingDrmSession); + } + } finally { + drmSession = null; + pendingDrmSession = null; + decoderCounters.ensureUpdated(); + eventDispatcher.disabled(decoderCounters); + } + } } } @@ -378,10 +474,18 @@ public final class LibvpxVideoRenderer extends BaseRenderer { decoder.release(); decoder = null; decoderCounters.decoderReleaseCount++; + waitingForKeys = false; + if (drmSession != null && pendingDrmSession != drmSession) { + try { + drmSessionManager.releaseSession(drmSession); + } finally { + drmSession = null; + } + } } } - private boolean readFormat() { + private boolean readFormat() throws ExoPlaybackException { int result = readSource(formatHolder, null); if (result == C.RESULT_FORMAT_READ) { onInputFormatChanged(formatHolder.format); @@ -390,42 +494,56 @@ public final class LibvpxVideoRenderer extends BaseRenderer { return false; } - private void onInputFormatChanged(Format newFormat) { + private void onInputFormatChanged(Format newFormat) throws ExoPlaybackException { + Format oldFormat = format; format = newFormat; + + boolean drmInitDataChanged = !Util.areEqual(format.drmInitData, oldFormat == null ? null + : oldFormat.drmInitData); + if (drmInitDataChanged) { + if (format.drmInitData != null) { + if (drmSessionManager == null) { + throw ExoPlaybackException.createForRenderer( + new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); + } + pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), format.drmInitData); + if (pendingDrmSession == drmSession) { + drmSessionManager.releaseSession(pendingDrmSession); + } + } else { + pendingDrmSession = null; + } + } + eventDispatcher.inputFormatChanged(format); } @Override public void handleMessage(int messageType, Object message) throws ExoPlaybackException { if (messageType == C.MSG_SET_SURFACE) { - setSurface((Surface) message); + setOutput((Surface) message, null); } else if (messageType == MSG_SET_OUTPUT_BUFFER_RENDERER) { - setOutputBufferRenderer((VpxOutputBufferRenderer) message); + setOutput(null, (VpxOutputBufferRenderer) message); } else { super.handleMessage(messageType, message); } } - private void setSurface(Surface surface) { - if (this.surface == surface) { - return; - } + private void setOutput(Surface surface, VpxOutputBufferRenderer outputBufferRenderer) { + // At most one output may be non-null. Both may be null if the output is being cleared. + Assertions.checkState(surface == null || outputBufferRenderer == null); + // Clear state so that we always call the event listener with the video size and when a frame + // is rendered, even if the output hasn't changed. renderedFirstFrame = false; - this.surface = surface; - outputBufferRenderer = null; - outputMode = (surface != null) ? VpxDecoder.OUTPUT_MODE_RGB : VpxDecoder.OUTPUT_MODE_NONE; - updateDecoder(); - } - - private void setOutputBufferRenderer(VpxOutputBufferRenderer outputBufferRenderer) { - if (this.outputBufferRenderer == outputBufferRenderer) { - return; + clearLastReportedVideoSize(); + // We only need to update the decoder if the output has changed. + if (this.surface != surface || this.outputBufferRenderer != outputBufferRenderer) { + this.surface = surface; + this.outputBufferRenderer = outputBufferRenderer; + outputMode = outputBufferRenderer != null ? VpxDecoder.OUTPUT_MODE_YUV + : surface != null ? VpxDecoder.OUTPUT_MODE_RGB : VpxDecoder.OUTPUT_MODE_NONE; + updateDecoder(); } - this.outputBufferRenderer = outputBufferRenderer; - surface = null; - outputMode = (outputBufferRenderer != null) ? VpxDecoder.OUTPUT_MODE_YUV - : VpxDecoder.OUTPUT_MODE_NONE; - updateDecoder(); } private void updateDecoder() { @@ -442,10 +560,15 @@ public final class LibvpxVideoRenderer extends BaseRenderer { return surface != null || outputBufferRenderer != null; } - private void maybeNotifyVideoSizeChanged(final int width, final int height) { - if (previousWidth != width || previousHeight != height) { - previousWidth = width; - previousHeight = height; + private void clearLastReportedVideoSize() { + lastReportedWidth = Format.NO_VALUE; + lastReportedHeight = Format.NO_VALUE; + } + + private void maybeNotifyVideoSizeChanged(int width, int height) { + if (lastReportedWidth != width || lastReportedHeight != height) { + lastReportedWidth = width; + lastReportedHeight = height; eventDispatcher.videoSizeChanged(width, height, 0, 1); } } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java index 5407e94f42..0d7547d125 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java @@ -16,8 +16,11 @@ package com.google.android.exoplayer2.ext.vp9; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; +import com.google.android.exoplayer2.drm.DecryptionException; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import java.nio.ByteBuffer; /** @@ -30,6 +33,11 @@ import java.nio.ByteBuffer; public static final int OUTPUT_MODE_YUV = 0; public static final int OUTPUT_MODE_RGB = 1; + private static final int NO_ERROR = 0; + private static final int DECODE_ERROR = 1; + private static final int DRM_ERROR = 2; + + private final ExoMediaCrypto exoMediaCrypto; private final long vpxDecContext; private volatile int outputMode; @@ -40,14 +48,20 @@ import java.nio.ByteBuffer; * @param numInputBuffers The number of input buffers. * @param numOutputBuffers The number of output buffers. * @param initialInputBufferSize The initial size of each input buffer. + * @param exoMediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted + * content. Maybe null and can be ignored if decoder does not handle encrypted content. * @throws VpxDecoderException Thrown if an exception occurs when initializing the decoder. */ - public VpxDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize) - throws VpxDecoderException { + public VpxDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, + ExoMediaCrypto exoMediaCrypto) throws VpxDecoderException { super(new DecoderInputBuffer[numInputBuffers], new VpxOutputBuffer[numOutputBuffers]); if (!VpxLibrary.isAvailable()) { throw new VpxDecoderException("Failed to load decoder native libraries."); } + this.exoMediaCrypto = exoMediaCrypto; + if (exoMediaCrypto != null && !VpxLibrary.vpxIsSecureDecodeSupported()) { + throw new VpxDecoderException("Vpx decoder does not support secure decode."); + } vpxDecContext = vpxInit(); if (vpxDecContext == 0) { throw new VpxDecoderException("Failed to initialize decoder"); @@ -90,12 +104,29 @@ import java.nio.ByteBuffer; boolean reset) { ByteBuffer inputData = inputBuffer.data; int inputSize = inputData.limit(); - if (vpxDecode(vpxDecContext, inputData, inputSize) != 0) { - return new VpxDecoderException("Decode error: " + vpxGetErrorMessage(vpxDecContext)); + CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; + final long result = inputBuffer.isEncrypted() + ? vpxSecureDecode(vpxDecContext, inputData, inputSize, exoMediaCrypto, + cryptoInfo.mode, cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) + : vpxDecode(vpxDecContext, inputData, inputSize); + if (result != NO_ERROR) { + if (result == DRM_ERROR) { + String message = "Drm error: " + vpxGetErrorMessage(vpxDecContext); + DecryptionException cause = new DecryptionException( + vpxGetErrorCode(vpxDecContext), message); + return new VpxDecoderException(message, cause); + } else { + return new VpxDecoderException("Decode error: " + vpxGetErrorMessage(vpxDecContext)); + } } + outputBuffer.init(inputBuffer.timeUs, outputMode); - if (vpxGetFrame(vpxDecContext, outputBuffer) != 0) { + int getFrameResult = vpxGetFrame(vpxDecContext, outputBuffer); + if (getFrameResult == 1) { outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); + } else if (getFrameResult == -1) { + return new VpxDecoderException("Buffer initialization failed."); } return null; } @@ -109,7 +140,11 @@ import java.nio.ByteBuffer; private native long vpxInit(); private native long vpxClose(long context); private native long vpxDecode(long context, ByteBuffer encoded, int length); + private native long vpxSecureDecode(long context, ByteBuffer encoded, int length, + ExoMediaCrypto wvCrypto, int inputMode, byte[] key, byte[] iv, + int numSubSamples, int[] numBytesOfClearData, int[] numBytesOfEncryptedData); private native int vpxGetFrame(long context, VpxOutputBuffer outputBuffer); + private native int vpxGetErrorCode(long context); private native String vpxGetErrorMessage(long context); } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoderException.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoderException.java index 94ed8e9fdd..5f43b503ac 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoderException.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoderException.java @@ -20,8 +20,11 @@ package com.google.android.exoplayer2.ext.vp9; */ public class VpxDecoderException extends Exception { - /* package */ VpxDecoderException(String message) { - super(message); - } + /* package */ VpxDecoderException(String message) { + super(message); + } + /* package */ VpxDecoderException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java index 6c694ebd2c..2caa33c17c 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java @@ -59,5 +59,5 @@ public final class VpxLibrary { private static native String vpxGetVersion(); private static native String vpxGetBuildConfig(); - + public static native boolean vpxIsSecureDecodeSupported(); } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java index d07b1443fd..c76d0eda03 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java @@ -66,28 +66,39 @@ import java.nio.ByteBuffer; /** * Resizes the buffer based on the given dimensions. Called via JNI after decoding completes. + * @return Whether the buffer was resized successfully. */ - public void initForRgbFrame(int width, int height) { + public boolean initForRgbFrame(int width, int height) { this.width = width; this.height = height; this.yuvPlanes = null; - + if (!isSafeToMultiply(width, height) || !isSafeToMultiply(width * height, 2)) { + return false; + } int minimumRgbSize = width * height * 2; initData(minimumRgbSize); + return true; } /** * Resizes the buffer based on the given stride. Called via JNI after decoding completes. + * @return Whether the buffer was resized successfully. */ - public void initForYuvFrame(int width, int height, int yStride, int uvStride, + public boolean initForYuvFrame(int width, int height, int yStride, int uvStride, int colorspace) { this.width = width; this.height = height; this.colorspace = colorspace; - + int uvHeight = (int) (((long) height + 1) / 2); + if (!isSafeToMultiply(yStride, height) || !isSafeToMultiply(uvStride, uvHeight)) { + return false; + } int yLength = yStride * height; - int uvLength = uvStride * ((height + 1) / 2); + int uvLength = uvStride * uvHeight; int minimumYuvSize = yLength + (uvLength * 2); + if (!isSafeToMultiply(uvLength, 2) || minimumYuvSize < yLength) { + return false; + } initData(minimumYuvSize); if (yuvPlanes == null) { @@ -108,6 +119,7 @@ import java.nio.ByteBuffer; yuvStrides[0] = yStride; yuvStrides[1] = uvStride; yuvStrides[2] = uvStride; + return true; } private void initData(int size) { @@ -119,4 +131,12 @@ import java.nio.ByteBuffer; } } + /** + * Ensures that the result of multiplying individual numbers can fit into the size limit of an + * integer. + */ + private boolean isSafeToMultiply(int a, int b) { + return a >= 0 && b >= 0 && !(b > 0 && a >= Integer.MAX_VALUE / b); + } + } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxRenderer.java index a0eccb41a7..d108ae8b4f 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxRenderer.java @@ -73,10 +73,13 @@ import javax.microedition.khronos.opengles.GL10; private final int[] yuvTextures = new int[3]; private final AtomicReference pendingOutputBufferReference; + // Kept in a field rather than a local variable so that it doesn't get garbage collected before + // glDrawArrays uses it. + @SuppressWarnings("FieldCanBeLocal") + private FloatBuffer textureCoords; private int program; private int texLocation; private int colorMatrixLocation; - private FloatBuffer textureCoords; private int previousWidth; private int previousStride; diff --git a/extensions/vp9/src/main/jni/vpx_jni.cc b/extensions/vp9/src/main/jni/vpx_jni.cc index a07b30a728..137ff9ac21 100644 --- a/extensions/vp9/src/main/jni/vpx_jni.cc +++ b/extensions/vp9/src/main/jni/vpx_jni.cc @@ -59,6 +59,7 @@ static jmethodID initForRgbFrame; static jmethodID initForYuvFrame; static jfieldID dataField; static jfieldID outputModeField; +static int errorCode; jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; @@ -72,6 +73,7 @@ DECODER_FUNC(jlong, vpxInit) { vpx_codec_ctx_t* context = new vpx_codec_ctx_t(); vpx_codec_dec_cfg_t cfg = {0, 0, 0}; cfg.threads = android_getCpuCount(); + errorCode = 0; if (vpx_codec_dec_init(context, &vpx_codec_vp9_dx_algo, &cfg, 0)) { LOGE("ERROR: Fail to initialize libvpx decoder."); return 0; @@ -81,9 +83,9 @@ DECODER_FUNC(jlong, vpxInit) { const jclass outputBufferClass = env->FindClass( "com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer"); initForYuvFrame = env->GetMethodID(outputBufferClass, "initForYuvFrame", - "(IIIII)V"); + "(IIIII)Z"); initForRgbFrame = env->GetMethodID(outputBufferClass, "initForRgbFrame", - "(II)V"); + "(II)Z"); dataField = env->GetFieldID(outputBufferClass, "data", "Ljava/nio/ByteBuffer;"); outputModeField = env->GetFieldID(outputBufferClass, "mode", "I"); @@ -97,13 +99,26 @@ DECODER_FUNC(jlong, vpxDecode, jlong jContext, jobject encoded, jint len) { reinterpret_cast(env->GetDirectBufferAddress(encoded)); const vpx_codec_err_t status = vpx_codec_decode(context, buffer, len, NULL, 0); + errorCode = 0; if (status != VPX_CODEC_OK) { LOGE("ERROR: vpx_codec_decode() failed, status= %d", status); + errorCode = status; return -1; } return 0; } +DECODER_FUNC(jlong, vpxSecureDecode, jlong jContext, jobject encoded, jint len, + jobject mediaCrypto, jint inputMode, jbyteArray&, jbyteArray&, + jint inputNumSubSamples, jintArray numBytesOfClearData, + jintArray numBytesOfEncryptedData) { + // Doesn't support + // Java client should have checked vpxSupportSecureDecode + // and avoid calling this + // return -2 (DRM Error) + return -2; +} + DECODER_FUNC(jlong, vpxClose, jlong jContext) { vpx_codec_ctx_t* const context = reinterpret_cast(jContext); vpx_codec_destroy(context); @@ -126,7 +141,11 @@ DECODER_FUNC(jint, vpxGetFrame, jlong jContext, jobject jOutputBuffer) { int outputMode = env->GetIntField(jOutputBuffer, outputModeField); if (outputMode == kOutputModeRgb) { // resize buffer if required. - env->CallVoidMethod(jOutputBuffer, initForRgbFrame, img->d_w, img->d_h); + jboolean initResult = env->CallBooleanMethod(jOutputBuffer, initForRgbFrame, + img->d_w, img->d_h); + if (initResult == JNI_FALSE) { + return -1; + } // get pointer to the data buffer. const jobject dataObject = env->GetObjectField(jOutputBuffer, dataField); @@ -155,9 +174,12 @@ DECODER_FUNC(jint, vpxGetFrame, jlong jContext, jobject jOutputBuffer) { } // resize buffer if required. - env->CallVoidMethod(jOutputBuffer, initForYuvFrame, img->d_w, img->d_h, - img->stride[VPX_PLANE_Y], img->stride[VPX_PLANE_U], - colorspace); + jboolean initResult = env->CallBooleanMethod( + jOutputBuffer, initForYuvFrame, img->d_w, img->d_h, + img->stride[VPX_PLANE_Y], img->stride[VPX_PLANE_U], colorspace); + if (initResult == JNI_FALSE) { + return -1; + } // get pointer to the data buffer. const jobject dataObject = env->GetObjectField(jOutputBuffer, dataField); @@ -181,6 +203,15 @@ DECODER_FUNC(jstring, vpxGetErrorMessage, jlong jContext) { return env->NewStringUTF(vpx_codec_error(context)); } +DECODER_FUNC(jint, vpxGetErrorCode, jlong jContext) { + return errorCode; +} + +LIBRARY_FUNC(jstring, vpxIsSecureDecodeSupported) { + // Doesn't support + return 0; +} + LIBRARY_FUNC(jstring, vpxGetVersion) { return env->NewStringUTF(vpx_codec_version_str()); } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 04363c87a9..c41838fae2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Sep 01 11:39:15 BST 2016 +#Mon Oct 24 14:40:37 BST 2016 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip diff --git a/library/build.gradle b/library/build.gradle index 42e7cb3bb1..5ec947d0eb 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -35,9 +35,11 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' } - debug { - testCoverageEnabled = true - } + // Re-enable test coverage when the following issue is fixed: + // https://code.google.com/p/android/issues/detail?id=226070 + // debug { + // testCoverageEnabled = true + // } } lintOptions { @@ -55,7 +57,7 @@ dependencies { androidTestCompile 'com.google.dexmaker:dexmaker:1.2' androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.2' androidTestCompile 'org.mockito:mockito-core:1.9.5' - compile 'com.android.support:support-annotations:24.2.0' + compile 'com.android.support:support-annotations:25.0.1' } android.libraryVariants.all { variant -> diff --git a/library/src/androidTest/assets/dash/sample_mpd_3_segment_template b/library/src/androidTest/assets/dash/sample_mpd_3_segment_template new file mode 100644 index 0000000000..a9147b54df --- /dev/null +++ b/library/src/androidTest/assets/dash/sample_mpd_3_segment_template @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/140/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/audio%2Fmp4/live/1/gir/yes/noclen/1/signature/B5137EA0CC278C07DD056D204E863CC81EDEB39E.1AD5D242EBC94922EDA7165353A89A5E08A4103A/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/133/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/video%2Fmp4/live/1/gir/yes/noclen/1/signature/90154AE9C5C9D9D519CBF2E43AB0A1778375992D.40E2E855ADFB38FA7E95E168FEEEA6796B080BD7/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/134/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/video%2Fmp4/live/1/gir/yes/noclen/1/signature/5C094AEFDCEB1A4D2F3C05F8BD095C336EF0E1C3.7AE6B9951B0237AAE6F031927AACAC4974BAFFAA/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/135/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/video%2Fmp4/live/1/gir/yes/noclen/1/signature/1F7660CA4E5B4AE4D60E18795680E34CDD2EF3C9.800B0A1D5F490DE142CCF4C88C64FD21D42129/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/160/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/video%2Fmp4/live/1/gir/yes/noclen/1/signature/94EB61673784DF0C4237A1A866F2E171C8A64ADB.AEC00AA06C2278FEA8702FB62693B70D8977F46C/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + http://redirector.googlevideo.com/videoplayback/id/BktsoMO3OMs.0/itag/136/source/yt_live_broadcast/ratebypass/yes/cmbypass/yes/mime/video%2Fmp4/live/1/gir/yes/noclen/1/signature/6D8C34FC30A1F1A4F700B61180D1C4CCF6274844.29EBCB4A837DE626C52C66CF650519E61C2FF0BF/key/dg_test0/mpd_version/5/ip/0.0.0.0/ipbits/0/expire/1476490914/sparams/ip,ipbits,expire,id,itag,source,ratebypass,cmbypass,mime,live,gir,noclen/ + + + + + diff --git a/library/src/androidTest/assets/ogg/bear.opus.0.dump b/library/src/androidTest/assets/ogg/bear.opus.0.dump index 3826692659..8033ce8089 100644 --- a/library/src/androidTest/assets/ogg/bear.opus.0.dump +++ b/library/src/androidTest/assets/ogg/bear.opus.0.dump @@ -22,7 +22,7 @@ track 0: encoderPadding = -1 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 - language = und + language = null drmInitData = - initializationData: data = length 19, hash BFE794DB diff --git a/library/src/androidTest/assets/ogg/bear.opus.1.dump b/library/src/androidTest/assets/ogg/bear.opus.1.dump index f073d36b27..f9aceae68a 100644 --- a/library/src/androidTest/assets/ogg/bear.opus.1.dump +++ b/library/src/androidTest/assets/ogg/bear.opus.1.dump @@ -22,7 +22,7 @@ track 0: encoderPadding = -1 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 - language = und + language = null drmInitData = - initializationData: data = length 19, hash BFE794DB diff --git a/library/src/androidTest/assets/ogg/bear.opus.2.dump b/library/src/androidTest/assets/ogg/bear.opus.2.dump index 6e27201631..f2f07f3e2f 100644 --- a/library/src/androidTest/assets/ogg/bear.opus.2.dump +++ b/library/src/androidTest/assets/ogg/bear.opus.2.dump @@ -22,7 +22,7 @@ track 0: encoderPadding = -1 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 - language = und + language = null drmInitData = - initializationData: data = length 19, hash BFE794DB diff --git a/library/src/androidTest/assets/ogg/bear.opus.3.dump b/library/src/androidTest/assets/ogg/bear.opus.3.dump index 8d4f451698..905055797c 100644 --- a/library/src/androidTest/assets/ogg/bear.opus.3.dump +++ b/library/src/androidTest/assets/ogg/bear.opus.3.dump @@ -22,7 +22,7 @@ track 0: encoderPadding = -1 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 - language = und + language = null drmInitData = - initializationData: data = length 19, hash BFE794DB diff --git a/library/src/androidTest/assets/ogg/bear.opus.unklen.dump b/library/src/androidTest/assets/ogg/bear.opus.unklen.dump index 070c9ef8a6..cd29da3e27 100644 --- a/library/src/androidTest/assets/ogg/bear.opus.unklen.dump +++ b/library/src/androidTest/assets/ogg/bear.opus.unklen.dump @@ -22,7 +22,7 @@ track 0: encoderPadding = -1 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 - language = und + language = null drmInitData = - initializationData: data = length 19, hash BFE794DB diff --git a/library/src/androidTest/assets/subrip/typical_negative_timestamps b/library/src/androidTest/assets/subrip/typical_negative_timestamps new file mode 100644 index 0000000000..2a47c0993b --- /dev/null +++ b/library/src/androidTest/assets/subrip/typical_negative_timestamps @@ -0,0 +1,12 @@ +1 +-0:00:04,567 --> -0:00:03,456 +This is the first subtitle. + +2 +-00:00:02,345 --> 00:00:01,234 +This is the second subtitle. +Second subtitle with second line. + +3 +00:00:04,567 --> 00:00:08,901 +This is the third subtitle. diff --git a/library/src/androidTest/assets/ts/sample_with_sdt.ts b/library/src/androidTest/assets/ts/sample_with_sdt.ts new file mode 100644 index 0000000000..8d1eccee15 Binary files /dev/null and b/library/src/androidTest/assets/ts/sample_with_sdt.ts differ diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java new file mode 100644 index 0000000000..be18d64195 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import android.os.Handler; +import android.os.HandlerThread; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import junit.framework.TestCase; + +/** + * Unit test for {@link ExoPlayer}. + */ +public final class ExoPlayerTest extends TestCase { + + /** + * For tests that rely on the player transitioning to the ended state, the duration in + * milliseconds after starting the player before the test will time out. This is to catch cases + * where the player under test is not making progress, in which case the test should fail. + */ + private static final int TIMEOUT_MS = 10000; + + public void testPlayToEnd() throws Exception { + PlayerWrapper playerWrapper = new PlayerWrapper(); + Format format = Format.createVideoSampleFormat(null, MimeTypes.VIDEO_H264, null, + Format.NO_VALUE, Format.NO_VALUE, 1280, 720, Format.NO_VALUE, null, null); + playerWrapper.setup(new SinglePeriodTimeline(0, false), new Object(), format); + playerWrapper.blockUntilEndedOrError(TIMEOUT_MS); + } + + /** + * Wraps a player with its own handler thread. + */ + private static final class PlayerWrapper implements ExoPlayer.EventListener { + + private final CountDownLatch endedCountDownLatch; + private final HandlerThread playerThread; + private final Handler handler; + + private Timeline expectedTimeline; + private Object expectedManifest; + private Format expectedFormat; + private ExoPlayer player; + private Exception exception; + private boolean seenPositionDiscontinuity; + + public PlayerWrapper() { + endedCountDownLatch = new CountDownLatch(1); + playerThread = new HandlerThread("ExoPlayerTest thread"); + playerThread.start(); + handler = new Handler(playerThread.getLooper()); + } + + // Called on the test thread. + + public void blockUntilEndedOrError(long timeoutMs) throws Exception { + if (!endedCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + exception = new TimeoutException("Test playback timed out."); + } + release(); + + // Throw any pending exception (from playback, timing out or releasing). + if (exception != null) { + throw exception; + } + } + + public void setup(final Timeline timeline, final Object manifest, final Format format) { + expectedTimeline = timeline; + expectedManifest = manifest; + expectedFormat = format; + handler.post(new Runnable() { + @Override + public void run() { + try { + Renderer fakeRenderer = new FakeVideoRenderer(expectedFormat); + player = ExoPlayerFactory.newInstance(new Renderer[] {fakeRenderer}, + new DefaultTrackSelector()); + player.addListener(PlayerWrapper.this); + player.setPlayWhenReady(true); + player.prepare(new FakeMediaSource(timeline, manifest, format)); + } catch (Exception e) { + handlePlayerException(e); + } + } + }); + } + + public void release() throws InterruptedException { + handler.post(new Runnable() { + @Override + public void run() { + try { + if (player != null) { + player.release(); + } + } catch (Exception e) { + handlePlayerException(e); + } finally { + playerThread.quit(); + } + } + }); + playerThread.join(); + } + + private void handlePlayerException(Exception exception) { + if (this.exception == null) { + this.exception = exception; + } + endedCountDownLatch.countDown(); + } + + // ExoPlayer.EventListener implementation. + + @Override + public void onLoadingChanged(boolean isLoading) { + // Do nothing. + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == ExoPlayer.STATE_ENDED) { + endedCountDownLatch.countDown(); + } + } + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest) { + assertEquals(expectedTimeline, timeline); + assertEquals(expectedManifest, manifest); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, + TrackSelectionArray trackSelections) { + assertEquals(new TrackGroupArray(new TrackGroup(expectedFormat)), trackGroups); + } + + @Override + public void onPlayerError(ExoPlaybackException exception) { + this.exception = exception; + endedCountDownLatch.countDown(); + } + + @Override + public void onPositionDiscontinuity() { + assertFalse(seenPositionDiscontinuity); + assertEquals(0, player.getCurrentWindowIndex()); + assertEquals(0, player.getCurrentPeriodIndex()); + assertEquals(0, player.getCurrentPosition()); + assertEquals(0, player.getBufferedPosition()); + assertEquals(expectedTimeline, player.getCurrentTimeline()); + assertEquals(expectedManifest, player.getCurrentManifest()); + seenPositionDiscontinuity = true; + } + + } + + /** + * Fake {@link MediaSource} that provides a given timeline (which must have one period). Creating + * the period will return a {@link FakeMediaPeriod}. + */ + private static final class FakeMediaSource implements MediaSource { + + private final Timeline timeline; + private final Object manifest; + private final Format format; + + private FakeMediaPeriod mediaPeriod; + private boolean preparedSource; + private boolean releasedPeriod; + private boolean releasedSource; + + public FakeMediaSource(Timeline timeline, Object manifest, Format format) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + this.timeline = timeline; + this.manifest = manifest; + this.format = format; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + assertFalse(preparedSource); + preparedSource = true; + listener.onSourceInfoRefreshed(timeline, manifest); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + assertTrue(preparedSource); + } + + @Override + public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { + assertTrue(preparedSource); + assertNull(mediaPeriod); + assertFalse(releasedPeriod); + assertFalse(releasedSource); + assertEquals(0, index); + assertEquals(0, positionUs); + mediaPeriod = new FakeMediaPeriod(format); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + assertTrue(preparedSource); + assertNotNull(this.mediaPeriod); + assertFalse(releasedPeriod); + assertFalse(releasedSource); + assertEquals(this.mediaPeriod, mediaPeriod); + this.mediaPeriod.release(); + releasedPeriod = true; + } + + @Override + public void releaseSource() { + assertTrue(preparedSource); + assertNotNull(this.mediaPeriod); + assertTrue(releasedPeriod); + assertFalse(releasedSource); + releasedSource = true; + } + + } + + /** + * Fake {@link MediaPeriod} that provides one track with a given {@link Format}. Selecting that + * track will give the player a {@link FakeSampleStream}. + */ + private static final class FakeMediaPeriod implements MediaPeriod { + + private final TrackGroup trackGroup; + + private boolean preparedPeriod; + + public FakeMediaPeriod(Format format) { + trackGroup = new TrackGroup(format); + } + + public void release() { + preparedPeriod = false; + } + + @Override + public void prepare(Callback callback) { + assertFalse(preparedPeriod); + preparedPeriod = true; + callback.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + assertTrue(preparedPeriod); + } + + @Override + public TrackGroupArray getTrackGroups() { + assertTrue(preparedPeriod); + return new TrackGroupArray(trackGroup); + } + + @Override + public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, + SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { + assertTrue(preparedPeriod); + assertEquals(1, selections.length); + assertEquals(1, mayRetainStreamFlags.length); + assertEquals(1, streams.length); + assertEquals(1, streamResetFlags.length); + assertEquals(0, positionUs); + if (streams[0] != null && (selections[0] == null || !mayRetainStreamFlags[0])) { + streams[0] = null; + } + if (streams[0] == null && selections[0] != null) { + FakeSampleStream stream = new FakeSampleStream(trackGroup.getFormat(0)); + assertEquals(trackGroup, selections[0].getTrackGroup()); + streams[0] = stream; + streamResetFlags[0] = true; + } + return 0; + } + + @Override + public long readDiscontinuity() { + assertTrue(preparedPeriod); + return C.TIME_UNSET; + } + + @Override + public long getBufferedPositionUs() { + assertTrue(preparedPeriod); + return C.TIME_END_OF_SOURCE; + } + + @Override + public long seekToUs(long positionUs) { + assertTrue(preparedPeriod); + assertEquals(0, positionUs); + return positionUs; + } + + @Override + public long getNextLoadPositionUs() { + assertTrue(preparedPeriod); + return 0; + } + + @Override + public boolean continueLoading(long positionUs) { + assertTrue(preparedPeriod); + return false; + } + + } + + /** + * Fake {@link SampleStream} that outputs a given {@link Format} then sets the end of stream flag + * on its input buffer. + */ + private static final class FakeSampleStream implements SampleStream { + + private final Format format; + + private boolean readFormat; + private boolean readEndOfStream; + + public FakeSampleStream(Format format) { + this.format = format; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer) { + Assertions.checkState(!readEndOfStream); + if (readFormat) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + readEndOfStream = true; + return C.RESULT_BUFFER_READ; + } + formatHolder.format = format; + readFormat = true; + return C.RESULT_FORMAT_READ; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public void skipToKeyframeBefore(long timeUs) { + // Do nothing. + } + + } + + /** + * Fake {@link Renderer} that supports any video format. The renderer verifies that it reads a + * given {@link Format} then a buffer with the end of stream flag set. + */ + private static final class FakeVideoRenderer extends BaseRenderer { + + private final Format expectedFormat; + + private boolean isEnded; + + public FakeVideoRenderer(Format expectedFormat) { + super(C.TRACK_TYPE_VIDEO); + Assertions.checkArgument(MimeTypes.isVideo(expectedFormat.sampleMimeType)); + this.expectedFormat = expectedFormat; + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (isEnded) { + return; + } + + // Verify the format matches the expected format. + FormatHolder formatHolder = new FormatHolder(); + readSource(formatHolder, null); + assertEquals(expectedFormat, formatHolder.format); + + // Verify that we get an end-of-stream buffer. + DecoderInputBuffer buffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + readSource(null, buffer); + assertTrue(buffer.isEndOfStream()); + isEnded = true; + } + + @Override + public boolean isReady() { + return isEnded; + } + + @Override + public boolean isEnded() { + return isEnded; + } + + @Override + public int supportsFormat(Format format) throws ExoPlaybackException { + return MimeTypes.isVideo(format.sampleMimeType) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; + } + + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java index 9bdf330b02..c8c1b4ed93 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java @@ -24,6 +24,8 @@ import android.annotation.TargetApi; import android.media.MediaFormat; import android.os.Parcel; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; @@ -56,11 +58,14 @@ public final class FormatTest extends TestCase { TestUtil.buildTestData(128, 1 /* data seed */)); DrmInitData drmInitData = new DrmInitData(DRM_DATA_1, DRM_DATA_2); byte[] projectionData = new byte[] {1, 2, 3}; + Metadata metadata = new Metadata( + new TextInformationFrame("id1", "description1"), + new TextInformationFrame("id2", "description2")); Format formatToParcel = new Format("id", MimeTypes.VIDEO_MP4, MimeTypes.VIDEO_H264, null, 1024, 2048, 1920, 1080, 24, 90, 2, projectionData, C.STEREO_MODE_TOP_BOTTOM, 6, 44100, - C.ENCODING_PCM_24BIT, 1001, 1002, 0, "und", Format.OFFSET_SAMPLE_RELATIVE, INIT_DATA, - drmInitData); + C.ENCODING_PCM_24BIT, 1001, 1002, 0, "und", Format.NO_VALUE, Format.OFFSET_SAMPLE_RELATIVE, + INIT_DATA, drmInitData, metadata); Parcel parcel = Parcel.obtain(); formatToParcel.writeToParcel(parcel, 0); diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java index cccc619a0e..cb1751d43b 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java @@ -27,9 +27,9 @@ import junit.framework.TestCase; */ public final class DefaultOggSeekerTest extends TestCase { - public void testSetupUnboundAudioLength() { + public void testSetupWithUnsetEndPositionFails() { try { - new DefaultOggSeeker(0, C.LENGTH_UNSET, new TestStreamReader()); + new DefaultOggSeeker(0, C.LENGTH_UNSET, new TestStreamReader(), 1, 1); fail(); } catch (IllegalArgumentException e) { // ignored @@ -43,11 +43,12 @@ public final class DefaultOggSeekerTest extends TestCase { } } - public void testSeeking(Random random) throws IOException, InterruptedException { + private void testSeeking(Random random) throws IOException, InterruptedException { OggTestFile testFile = OggTestFile.generate(random, 1000); FakeExtractorInput input = new FakeExtractorInput.Builder().setData(testFile.data).build(); TestStreamReader streamReader = new TestStreamReader(); - DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, testFile.data.length, streamReader); + DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, testFile.data.length, streamReader, + testFile.firstPayloadPageSize, testFile.firstPayloadPageGranulePosition); OggPageHeader pageHeader = new OggPageHeader(); while (true) { @@ -109,8 +110,8 @@ public final class DefaultOggSeekerTest extends TestCase { long granuleDiff = currentGranule - targetGranule; if ((granuleDiff > DefaultOggSeeker.MATCH_RANGE || granuleDiff < 0) && positionDiff > DefaultOggSeeker.MATCH_BYTE_RANGE) { - fail(String.format("granuleDiff (%d) or positionDiff (%d) is more than allowed.", - granuleDiff, positionDiff)); + fail("granuleDiff (" + granuleDiff + ") or positionDiff (" + positionDiff + + ") is more than allowed."); } } } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerUtilMethodsTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerUtilMethodsTest.java index 052f45b8f4..d52deb108f 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerUtilMethodsTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerUtilMethodsTest.java @@ -28,8 +28,8 @@ import junit.framework.TestCase; */ public class DefaultOggSeekerUtilMethodsTest extends TestCase { - private Random random = new Random(0); - + private final Random random = new Random(0); + public void testSkipToNextPage() throws Exception { FakeExtractorInput extractorInput = TestData.createInput( TestUtil.joinByteArrays( @@ -75,7 +75,7 @@ public class DefaultOggSeekerUtilMethodsTest extends TestCase { private static void skipToNextPage(ExtractorInput extractorInput) throws IOException, InterruptedException { DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, extractorInput.getLength(), - new FlacReader()); + new FlacReader(), 1, 2); while (true) { try { oggSeeker.skipToNextPage(extractorInput); @@ -143,7 +143,7 @@ public class DefaultOggSeekerUtilMethodsTest extends TestCase { private void skipToPageOfGranule(ExtractorInput input, long granule, long elapsedSamplesExpected) throws IOException, InterruptedException { - DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, input.getLength(), new FlacReader()); + DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, input.getLength(), new FlacReader(), 1, 2); while (true) { try { assertEquals(elapsedSamplesExpected, oggSeeker.skipToPageOfGranule(input, granule, -1)); @@ -193,7 +193,7 @@ public class DefaultOggSeekerUtilMethodsTest extends TestCase { private void assertReadGranuleOfLastPage(FakeExtractorInput input, int expected) throws IOException, InterruptedException { - DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, input.getLength(), new FlacReader()); + DefaultOggSeeker oggSeeker = new DefaultOggSeeker(0, input.getLength(), new FlacReader(), 1, 2); while (true) { try { assertEquals(expected, oggSeeker.readGranuleOfLastPage(input)); diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/OggTestFile.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/OggTestFile.java index 88f36d35c1..d5d187ee7c 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/OggTestFile.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/OggTestFile.java @@ -25,20 +25,25 @@ import junit.framework.Assert; */ /* package */ final class OggTestFile { - public static final int MAX_PACKET_LENGTH = 2048; - public static final int MAX_SEGMENT_COUNT = 10; - public static final int MAX_GRANULES_IN_PAGE = 100000; + private static final int MAX_PACKET_LENGTH = 2048; + private static final int MAX_SEGMENT_COUNT = 10; + private static final int MAX_GRANULES_IN_PAGE = 100000; - byte[] data; - long lastGranule; - int packetCount; - int pageCount; + public final byte[] data; + public final long lastGranule; + public final int packetCount; + public final int pageCount; + public final int firstPayloadPageSize; + public final long firstPayloadPageGranulePosition; - private OggTestFile(byte[] data, long lastGranule, int packetCount, int pageCount) { + private OggTestFile(byte[] data, long lastGranule, int packetCount, int pageCount, + int firstPayloadPageSize, long firstPayloadPageGranulePosition) { this.data = data; this.lastGranule = lastGranule; this.packetCount = packetCount; this.pageCount = pageCount; + this.firstPayloadPageSize = firstPayloadPageSize; + this.firstPayloadPageGranulePosition = firstPayloadPageGranulePosition; } public static OggTestFile generate(Random random, int pageCount) { @@ -47,6 +52,8 @@ import junit.framework.Assert; long granule = 0; int packetLength = -1; int packetCount = 0; + int firstPayloadPageSize = 0; + long firstPayloadPageGranulePosition = 0; for (int i = 0; i < pageCount; i++) { int headerType = 0x00; @@ -89,6 +96,10 @@ import junit.framework.Assert; byte[] payload = TestUtil.buildTestData(bodySize, random); fileData.add(payload); fileSize += payload.length; + if (i == 0) { + firstPayloadPageSize = header.length + bodySize; + firstPayloadPageGranulePosition = granule; + } } byte[] file = new byte[fileSize]; @@ -97,7 +108,8 @@ import junit.framework.Assert; System.arraycopy(data, 0, file, position, data.length); position += data.length; } - return new OggTestFile(file, granule, packetCount, pageCount); + return new OggTestFile(file, granule, packetCount, pageCount, firstPayloadPageSize, + firstPayloadPageGranulePosition); } public int findPreviousPageStart(long position) { diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java index 2f4da01228..4e99e2745e 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java @@ -17,8 +17,10 @@ package com.google.android.exoplayer2.extractor.rawcc; import android.annotation.TargetApi; import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; /** * Tests for {@link RawCcExtractor}. @@ -27,12 +29,15 @@ import com.google.android.exoplayer2.testutil.TestUtil; public final class RawCcExtractorTest extends InstrumentationTestCase { public void testRawCcSample() throws Exception { - TestUtil.assertOutput(new TestUtil.ExtractorFactory() { - @Override - public Extractor create() { - return new RawCcExtractor(); - } - }, "rawcc/sample.rawcc", getInstrumentation()); + TestUtil.assertOutput( + new TestUtil.ExtractorFactory() { + @Override + public Extractor create() { + return new RawCcExtractor( + Format.createTextContainerFormat(null, null, MimeTypes.APPLICATION_CEA608, + "cea608", Format.NO_VALUE, 0, null, 1)); + } + }, "rawcc/sample.rawcc", getInstrumentation()); } } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java index e19de76466..ebb547810b 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.extractor.ts; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.TrackIdGenerator; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.testutil.FakeExtractorOutput; import com.google.android.exoplayer2.testutil.FakeTrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; @@ -52,7 +52,7 @@ public class AdtsReaderTest extends TestCase { public static final byte[] ADTS_CONTENT = TestUtil.createByteArray( 0x20, 0x00, 0x20, 0x00, 0x00, 0x80, 0x0e); - private static final byte TEST_DATA[] = TestUtil.joinByteArrays( + private static final byte[] TEST_DATA = TestUtil.joinByteArrays( ID3_DATA_1, ID3_DATA_2, ADTS_HEADER, @@ -73,7 +73,7 @@ public class AdtsReaderTest extends TestCase { id3Output = fakeExtractorOutput.track(1); adtsReader = new AdtsReader(true); TrackIdGenerator idGenerator = new TrackIdGenerator(0, 1); - adtsReader.init(fakeExtractorOutput, idGenerator); + adtsReader.createTracks(fakeExtractorOutput, idGenerator); data = new ParsableByteArray(TEST_DATA); firstFeed = true; } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/SectionReaderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/SectionReaderTest.java new file mode 100644 index 0000000000..453a33a521 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/SectionReaderTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import junit.framework.TestCase; + +/** + * Test for {@link SectionReader}. + */ +public class SectionReaderTest extends TestCase { + + private byte[] packetPayload; + private CustomSectionPayloadReader payloadReader; + private SectionReader reader; + + @Override + public void setUp() { + packetPayload = new byte[512]; + Arrays.fill(packetPayload, (byte) 0xFF); + payloadReader = new CustomSectionPayloadReader(); + reader = new SectionReader(payloadReader); + reader.init(new TimestampAdjuster(0), new FakeExtractorOutput(), + new TsPayloadReader.TrackIdGenerator(0, 1)); + } + + public void testSingleOnePacketSection() { + packetPayload[0] = 3; + insertTableSection(4, (byte) 99, 3); + reader.consume(new ParsableByteArray(packetPayload), true); + assertEquals(Collections.singletonList(99), payloadReader.parsedTableIds); + } + + public void testHeaderSplitAcrossPackets() { + packetPayload[0] = 3; // The first packet includes a pointer_field. + insertTableSection(4, (byte) 100, 3); // This section header spreads across both packets. + + ParsableByteArray firstPacket = new ParsableByteArray(packetPayload, 5); + reader.consume(firstPacket, true); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray secondPacket = new ParsableByteArray(packetPayload); + secondPacket.setPosition(5); + reader.consume(secondPacket, false); + assertEquals(Collections.singletonList(100), payloadReader.parsedTableIds); + } + + public void testFiveSectionsInTwoPackets() { + packetPayload[0] = 0; // The first packet includes a pointer_field. + insertTableSection(1, (byte) 101, 10); + insertTableSection(14, (byte) 102, 10); + insertTableSection(27, (byte) 103, 10); + packetPayload[40] = 0; // The second packet includes a pointer_field. + insertTableSection(41, (byte) 104, 10); + insertTableSection(54, (byte) 105, 10); + + ParsableByteArray firstPacket = new ParsableByteArray(packetPayload, 40); + reader.consume(firstPacket, true); + assertEquals(Arrays.asList(101, 102, 103), payloadReader.parsedTableIds); + + ParsableByteArray secondPacket = new ParsableByteArray(packetPayload); + secondPacket.setPosition(40); + reader.consume(secondPacket, true); + assertEquals(Arrays.asList(101, 102, 103, 104, 105), payloadReader.parsedTableIds); + } + + public void testLongSectionAcrossFourPackets() { + packetPayload[0] = 13; // The first packet includes a pointer_field. + insertTableSection(1, (byte) 106, 10); // First section. Should be skipped. + // Second section spread across four packets. Should be consumed. + insertTableSection(14, (byte) 107, 300); + packetPayload[300] = 17; // The third packet includes a pointer_field. + // Third section, at the payload start of the fourth packet. Should be consumed. + insertTableSection(318, (byte) 108, 10); + + ParsableByteArray firstPacket = new ParsableByteArray(packetPayload, 100); + reader.consume(firstPacket, true); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray secondPacket = new ParsableByteArray(packetPayload, 200); + secondPacket.setPosition(100); + reader.consume(secondPacket, false); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray thirdPacket = new ParsableByteArray(packetPayload, 300); + thirdPacket.setPosition(200); + reader.consume(thirdPacket, false); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray fourthPacket = new ParsableByteArray(packetPayload); + fourthPacket.setPosition(300); + reader.consume(fourthPacket, true); + assertEquals(Arrays.asList(107, 108), payloadReader.parsedTableIds); + } + + public void testSeek() { + packetPayload[0] = 13; // The first packet includes a pointer_field. + insertTableSection(1, (byte) 109, 10); // First section. Should be skipped. + // Second section spread across four packets. Should be consumed. + insertTableSection(14, (byte) 110, 300); + packetPayload[300] = 17; // The third packet includes a pointer_field. + // Third section, at the payload start of the fourth packet. Should be consumed. + insertTableSection(318, (byte) 111, 10); + + ParsableByteArray firstPacket = new ParsableByteArray(packetPayload, 100); + reader.consume(firstPacket, true); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray secondPacket = new ParsableByteArray(packetPayload, 200); + secondPacket.setPosition(100); + reader.consume(secondPacket, false); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + ParsableByteArray thirdPacket = new ParsableByteArray(packetPayload, 300); + thirdPacket.setPosition(200); + reader.consume(thirdPacket, false); + assertEquals(Collections.emptyList(), payloadReader.parsedTableIds); + + reader.seek(); + + ParsableByteArray fourthPacket = new ParsableByteArray(packetPayload); + fourthPacket.setPosition(300); + reader.consume(fourthPacket, true); + assertEquals(Collections.singletonList(111), payloadReader.parsedTableIds); + } + + public void testCrcChecks() { + byte[] correctCrcPat = new byte[] { + (byte) 0x0, (byte) 0x0, (byte) 0xb0, (byte) 0xd, (byte) 0x0, (byte) 0x1, (byte) 0xc1, + (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x1, (byte) 0xe1, (byte) 0x0, (byte) 0xe8, + (byte) 0xf9, (byte) 0x5e, (byte) 0x7d}; + byte[] incorrectCrcPat = Arrays.copyOf(correctCrcPat, correctCrcPat.length); + // Crc field is incorrect, and should not be passed to the payload reader. + incorrectCrcPat[16]--; + reader.consume(new ParsableByteArray(correctCrcPat), true); + assertEquals(Collections.singletonList(0), payloadReader.parsedTableIds); + reader.consume(new ParsableByteArray(incorrectCrcPat), true); + assertEquals(Collections.singletonList(0), payloadReader.parsedTableIds); + } + + // Internal methods. + + /** + * Inserts a private section header to {@link #packetPayload}. + * + * @param offset The position at which the header is inserted. + * @param tableId The table_id for the inserted section. + * @param sectionLength The value to use for private_section_length. + */ + private void insertTableSection(int offset, byte tableId, int sectionLength) { + packetPayload[offset++] = tableId; + packetPayload[offset++] = (byte) ((sectionLength >> 8) & 0x0F); + packetPayload[offset] = (byte) (sectionLength & 0xFF); + } + + // Internal classes. + + private static final class CustomSectionPayloadReader implements SectionPayloadReader { + + List parsedTableIds; + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TsPayloadReader.TrackIdGenerator idGenerator) { + parsedTableIds = new ArrayList<>(); + } + + @Override + public void consume(ParsableByteArray sectionData) { + parsedTableIds.add(sectionData.readUnsignedByte()); + } + + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java index 1f08507599..c9d6535164 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java @@ -16,20 +16,21 @@ package com.google.android.exoplayer2.extractor.ts; import android.test.InstrumentationTestCase; +import android.util.SparseArray; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.TimestampAdjuster; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.EsInfo; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorOutput; import com.google.android.exoplayer2.testutil.FakeTrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.util.Random; /** @@ -72,7 +73,7 @@ public final class TsExtractorTest extends InstrumentationTestCase { } public void testCustomPesReader() throws Exception { - CustomEsReaderFactory factory = new CustomEsReaderFactory(); + CustomTsPayloadReaderFactory factory = new CustomTsPayloadReaderFactory(true, false); TsExtractor tsExtractor = new TsExtractor(new TimestampAdjuster(0), factory, false); FakeExtractorInput input = new FakeExtractorInput.Builder() .setData(TestUtil.getByteArray(getInstrumentation(), "ts/sample.ts")) @@ -81,13 +82,12 @@ public final class TsExtractorTest extends InstrumentationTestCase { .setSimulatePartialReads(false).build(); FakeExtractorOutput output = new FakeExtractorOutput(); tsExtractor.init(output); - tsExtractor.seek(input.getPosition()); PositionHolder seekPositionHolder = new PositionHolder(); int readResult = Extractor.RESULT_CONTINUE; while (readResult != Extractor.RESULT_END_OF_INPUT) { readResult = tsExtractor.read(input, seekPositionHolder); } - CustomEsReader reader = factory.reader; + CustomEsReader reader = factory.esReader; assertEquals(2, reader.packetsRead); TrackOutput trackOutput = reader.getTrackOutput(); assertTrue(trackOutput == output.trackOutputs.get(257 /* PID of audio track. */)); @@ -96,7 +96,24 @@ public final class TsExtractorTest extends InstrumentationTestCase { ((FakeTrackOutput) trackOutput).format); } - private static void writeJunkData(ByteArrayOutputStream out, int length) throws IOException { + public void testCustomInitialSectionReader() throws Exception { + CustomTsPayloadReaderFactory factory = new CustomTsPayloadReaderFactory(false, true); + TsExtractor tsExtractor = new TsExtractor(new TimestampAdjuster(0), factory, false); + FakeExtractorInput input = new FakeExtractorInput.Builder() + .setData(TestUtil.getByteArray(getInstrumentation(), "ts/sample_with_sdt.ts")) + .setSimulateIOErrors(false) + .setSimulateUnknownLength(false) + .setSimulatePartialReads(false).build(); + tsExtractor.init(new FakeExtractorOutput()); + PositionHolder seekPositionHolder = new PositionHolder(); + int readResult = Extractor.RESULT_CONTINUE; + while (readResult != Extractor.RESULT_END_OF_INPUT) { + readResult = tsExtractor.read(input, seekPositionHolder); + } + assertEquals(1, factory.sdtReader.consumedSdts); + } + + private static void writeJunkData(ByteArrayOutputStream out, int length) { for (int i = 0; i < length; i++) { if (((byte) i) == TS_SYNC_BYTE) { out.write(0); @@ -106,7 +123,46 @@ public final class TsExtractorTest extends InstrumentationTestCase { } } - private static final class CustomEsReader extends ElementaryStreamReader { + private static final class CustomTsPayloadReaderFactory implements TsPayloadReader.Factory { + + private final boolean provideSdtReader; + private final boolean provideCustomEsReader; + private final TsPayloadReader.Factory defaultFactory; + private CustomEsReader esReader; + private SdtSectionReader sdtReader; + + public CustomTsPayloadReaderFactory(boolean provideCustomEsReader, boolean provideSdtReader) { + this.provideCustomEsReader = provideCustomEsReader; + this.provideSdtReader = provideSdtReader; + defaultFactory = new DefaultTsPayloadReaderFactory(); + } + + @Override + public SparseArray createInitialPayloadReaders() { + if (provideSdtReader) { + assertNull(sdtReader); + SparseArray mapping = new SparseArray<>(); + sdtReader = new SdtSectionReader(); + mapping.put(17, new SectionReader(sdtReader)); + return mapping; + } else { + return defaultFactory.createInitialPayloadReaders(); + } + } + + @Override + public TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo) { + if (provideCustomEsReader && streamType == 3) { + esReader = new CustomEsReader(esInfo.language); + return new PesReader(esReader); + } else { + return defaultFactory.createPayloadReader(streamType, esInfo); + } + } + + } + + private static final class CustomEsReader implements ElementaryStreamReader { private final String language; private TrackOutput output; @@ -121,7 +177,7 @@ public final class TsExtractorTest extends InstrumentationTestCase { } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); output.format(Format.createTextSampleFormat("Overriding format", "mime", null, 0, 0, language, null, 0)); @@ -146,23 +202,44 @@ public final class TsExtractorTest extends InstrumentationTestCase { } - private static final class CustomEsReaderFactory implements ElementaryStreamReader.Factory { + private static final class SdtSectionReader implements SectionPayloadReader { - private final ElementaryStreamReader.Factory defaultFactory; - private CustomEsReader reader; + private int consumedSdts; - public CustomEsReaderFactory() { - defaultFactory = new DefaultStreamReaderFactory(); + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + // Do nothing. } @Override - public ElementaryStreamReader createStreamReader(int streamType, EsInfo esInfo) { - if (streamType == 3) { - reader = new CustomEsReader(esInfo.language); - return reader; - } else { - return defaultFactory.createStreamReader(streamType, esInfo); + public void consume(ParsableByteArray sectionData) { + // table_id(8), section_syntax_indicator(1), reserved_future_use(1), reserved(2), + // section_length(12), transport_stream_id(16), reserved(2), version_number(5), + // current_next_indicator(1), section_number(8), last_section_number(8), + // original_network_id(16), reserved_future_use(8) + sectionData.skipBytes(11); + // Start of the service loop. + assertEquals(0x5566 /* arbitrary service id */, sectionData.readUnsignedShort()); + // reserved_future_use(6), EIT_schedule_flag(1), EIT_present_following_flag(1) + sectionData.skipBytes(1); + // Assert there is only one service. + // Remove running_status(3), free_CA_mode(1) from the descriptors_loop_length with the mask. + assertEquals(sectionData.readUnsignedShort() & 0xFFF, sectionData.bytesLeft()); + while (sectionData.bytesLeft() > 0) { + int descriptorTag = sectionData.readUnsignedByte(); + int descriptorLength = sectionData.readUnsignedByte(); + if (descriptorTag == 72 /* service descriptor */) { + assertEquals(1, sectionData.readUnsignedByte()); // Service type: Digital TV. + int serviceProviderNameLength = sectionData.readUnsignedByte(); + assertEquals("Some provider", sectionData.readString(serviceProviderNameLength)); + int serviceNameLength = sectionData.readUnsignedByte(); + assertEquals("Some Channel", sectionData.readString(serviceNameLength)); + } else { + sectionData.skipBytes(descriptorLength); + } } + consumedSdts++; } } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java index f9ec1ee92b..6bfa6fccfc 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java @@ -16,8 +16,8 @@ package com.google.android.exoplayer2.metadata.id3; import android.test.MoreAsserts; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoderException; -import java.util.List; import junit.framework.TestCase; /** @@ -30,9 +30,9 @@ public class Id3DecoderTest extends TestCase { 3, 0, 109, 100, 105, 97, 108, 111, 103, 95, 86, 73, 78, 68, 73, 67, 79, 49, 53, 50, 55, 54, 54, 52, 95, 115, 116, 97, 114, 116, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - TxxxFrame txxxFrame = (TxxxFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + TxxxFrame txxxFrame = (TxxxFrame) metadata.get(0); assertEquals("", txxxFrame.description); assertEquals("mdialog_VINDICO1527664_start", txxxFrame.value); } @@ -42,9 +42,9 @@ public class Id3DecoderTest extends TestCase { 3, 105, 109, 97, 103, 101, 47, 106, 112, 101, 103, 0, 16, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - ApicFrame apicFrame = (ApicFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + ApicFrame apicFrame = (ApicFrame) metadata.get(0); assertEquals("image/jpeg", apicFrame.mimeType); assertEquals(16, apicFrame.pictureType); assertEquals("Hello World", apicFrame.description); @@ -56,9 +56,9 @@ public class Id3DecoderTest extends TestCase { byte[] rawId3 = new byte[] {73, 68, 51, 4, 0, 0, 0, 0, 0, 23, 84, 73, 84, 50, 0, 0, 0, 13, 0, 0, 3, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0}; Id3Decoder decoder = new Id3Decoder(); - List id3Frames = decoder.decode(rawId3, rawId3.length); - assertEquals(1, id3Frames.size()); - TextInformationFrame textInformationFrame = (TextInformationFrame) id3Frames.get(0); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertEquals("TIT2", textInformationFrame.id); assertEquals("Hello World", textInformationFrame.description); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java new file mode 100644 index 0000000000..0933fb858b --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static org.mockito.Mockito.doAnswer; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.Timeline.Window; +import com.google.android.exoplayer2.source.MediaSource.Listener; +import com.google.android.exoplayer2.testutil.TestUtil; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +/** + * Unit tests for {@link ClippingMediaSource}. + */ +public final class ClippingMediaSourceTest extends InstrumentationTestCase { + + private static final long TEST_PERIOD_DURATION_US = 1000000; + private static final long TEST_CLIP_AMOUNT_US = 300000; + + @Mock + private MediaSource mockMediaSource; + private Timeline clippedTimeline; + private Window window; + private Period period; + + @Override + protected void setUp() throws Exception { + TestUtil.setUpMockito(this); + window = new Timeline.Window(); + period = new Timeline.Period(); + } + + public void testNoClipping() { + Timeline timeline = new SinglePeriodTimeline(C.msToUs(TEST_PERIOD_DURATION_US), true); + + Timeline clippedTimeline = getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); + + assertEquals(1, clippedTimeline.getWindowCount()); + assertEquals(1, clippedTimeline.getPeriodCount()); + assertEquals(TEST_PERIOD_DURATION_US, clippedTimeline.getWindow(0, window).getDurationUs()); + assertEquals(TEST_PERIOD_DURATION_US, clippedTimeline.getPeriod(0, period).getDurationUs()); + } + + public void testClippingUnseekableWindowThrows() { + Timeline timeline = new SinglePeriodTimeline(C.msToUs(TEST_PERIOD_DURATION_US), false); + + // If the unseekable window isn't clipped, clipping succeeds. + getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); + try { + // If the unseekable window is clipped, clipping fails. + getClippedTimeline(timeline, 1, TEST_PERIOD_DURATION_US); + fail("Expected clipping to fail."); + } catch (IllegalArgumentException e) { + // Expected. + } + } + + public void testClippingStart() { + Timeline timeline = new SinglePeriodTimeline(C.msToUs(TEST_PERIOD_DURATION_US), true); + + Timeline clippedTimeline = getClippedTimeline(timeline, TEST_CLIP_AMOUNT_US, + TEST_PERIOD_DURATION_US); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US, + clippedTimeline.getWindow(0, window).getDurationUs()); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US, + clippedTimeline.getPeriod(0, period).getDurationUs()); + } + + public void testClippingEnd() { + Timeline timeline = new SinglePeriodTimeline(C.msToUs(TEST_PERIOD_DURATION_US), true); + + Timeline clippedTimeline = getClippedTimeline(timeline, 0, + TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US, + clippedTimeline.getWindow(0, window).getDurationUs()); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US, + clippedTimeline.getPeriod(0, period).getDurationUs()); + } + + public void testClippingStartAndEnd() { + Timeline timeline = new SinglePeriodTimeline(C.msToUs(TEST_PERIOD_DURATION_US), true); + + Timeline clippedTimeline = getClippedTimeline(timeline, TEST_CLIP_AMOUNT_US, + TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US * 2); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US * 3, + clippedTimeline.getWindow(0, window).getDurationUs()); + assertEquals(TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US * 3, + clippedTimeline.getPeriod(0, period).getDurationUs()); + } + + /** + * Wraps the specified timeline in a {@link ClippingMediaSource} and returns the clipped timeline. + */ + private Timeline getClippedTimeline(Timeline timeline, long startMs, long endMs) { + mockMediaSourceSourceWithTimeline(timeline); + new ClippingMediaSource(mockMediaSource, startMs, endMs).prepareSource(null, true, + new Listener() { + @Override + public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { + clippedTimeline = timeline; + } + }); + return clippedTimeline; + } + + /** + * Returns a mock {@link MediaSource} with the specified {@link Timeline} in its source info. + */ + private MediaSource mockMediaSourceSourceWithTimeline(final Timeline timeline) { + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + MediaSource.Listener listener = (MediaSource.Listener) invocation.getArguments()[2]; + listener.onSourceInfoRefreshed(timeline, null); + return null; + } + }).when(mockMediaSource).prepareSource(Mockito.any(ExoPlayer.class), Mockito.anyBoolean(), + Mockito.any(MediaSource.Listener.class)); + return mockMediaSource; + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 66ee298daf..944781b890 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.dash.manifest; import android.net.Uri; import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.testutil.TestUtil; import java.io.IOException; @@ -28,6 +29,8 @@ public class DashManifestParserTest extends InstrumentationTestCase { private static final String SAMPLE_MPD_1 = "dash/sample_mpd_1"; private static final String SAMPLE_MPD_2_UNKNOWN_MIME_TYPE = "dash/sample_mpd_2_unknown_mime_type"; + private static final String SAMPLE_MPD_3_SEGMENT_TEMPLATE = + "dash/sample_mpd_3_segment_template"; /** * Simple test to ensure the sample manifests parse without any exceptions being thrown. @@ -40,4 +43,61 @@ public class DashManifestParserTest extends InstrumentationTestCase { TestUtil.getInputStream(getInstrumentation(), SAMPLE_MPD_2_UNKNOWN_MIME_TYPE)); } + public void testParseMediaPresentationDescriptionWithSegmentTemplate() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest mpd = parser.parse(Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream(getInstrumentation(), SAMPLE_MPD_3_SEGMENT_TEMPLATE)); + + assertEquals(1, mpd.getPeriodCount()); + + Period period = mpd.getPeriod(0); + assertNotNull(period); + assertEquals(2, period.adaptationSets.size()); + + for (AdaptationSet adaptationSet : period.adaptationSets) { + assertNotNull(adaptationSet); + for (Representation representation : adaptationSet.representations) { + if (representation instanceof Representation.MultiSegmentRepresentation) { + Representation.MultiSegmentRepresentation multiSegmentRepresentation = + (Representation.MultiSegmentRepresentation) representation; + int firstSegmentIndex = multiSegmentRepresentation.getFirstSegmentNum(); + RangedUri uri = multiSegmentRepresentation.getSegmentUrl(firstSegmentIndex); + assertTrue(uri.resolveUriString(representation.baseUrl).contains( + "redirector.googlevideo.com")); + } + } + } + } + + public void testParseCea608AccessibilityChannel() { + assertEquals(1, DashManifestParser.parseCea608AccessibilityChannel("CC1=eng")); + assertEquals(2, DashManifestParser.parseCea608AccessibilityChannel("CC2=eng")); + assertEquals(3, DashManifestParser.parseCea608AccessibilityChannel("CC3=eng")); + assertEquals(4, DashManifestParser.parseCea608AccessibilityChannel("CC4=eng")); + + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea608AccessibilityChannel(null)); + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea608AccessibilityChannel("")); + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea608AccessibilityChannel("CC0=eng")); + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea608AccessibilityChannel("CC5=eng")); + assertEquals(Format.NO_VALUE, + DashManifestParser.parseCea608AccessibilityChannel("Wrong format")); + } + + public void testParseCea708AccessibilityChannel() { + assertEquals(1, DashManifestParser.parseCea708AccessibilityChannel("1=lang:eng")); + assertEquals(2, DashManifestParser.parseCea708AccessibilityChannel("2=lang:eng")); + assertEquals(3, DashManifestParser.parseCea708AccessibilityChannel("3=lang:eng")); + assertEquals(62, DashManifestParser.parseCea708AccessibilityChannel("62=lang:eng")); + assertEquals(63, DashManifestParser.parseCea708AccessibilityChannel("63=lang:eng")); + + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea708AccessibilityChannel(null)); + assertEquals(Format.NO_VALUE, DashManifestParser.parseCea708AccessibilityChannel("")); + assertEquals(Format.NO_VALUE, + DashManifestParser.parseCea708AccessibilityChannel("0=lang:eng")); + assertEquals(Format.NO_VALUE, + DashManifestParser.parseCea708AccessibilityChannel("64=lang:eng")); + assertEquals(Format.NO_VALUE, + DashManifestParser.parseCea708AccessibilityChannel("Wrong format")); + } + } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RangedUriTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RangedUriTest.java index 59e1c14a33..fd559381fa 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RangedUriTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RangedUriTest.java @@ -23,56 +23,64 @@ import junit.framework.TestCase; */ public class RangedUriTest extends TestCase { - private static final String FULL_URI = "http://www.test.com/path/file.ext"; + private static final String BASE_URI = "http://www.test.com/"; + private static final String PARTIAL_URI = "path/file.ext"; + private static final String FULL_URI = BASE_URI + PARTIAL_URI; public void testMerge() { - RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); - RangedUri rangeB = new RangedUri(null, FULL_URI, 10, 10); - RangedUri expected = new RangedUri(null, FULL_URI, 0, 20); - assertMerge(rangeA, rangeB, expected); + RangedUri rangeA = new RangedUri(FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(FULL_URI, 10, 10); + RangedUri expected = new RangedUri(FULL_URI, 0, 20); + assertMerge(rangeA, rangeB, expected, null); } public void testMergeUnbounded() { - RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); - RangedUri rangeB = new RangedUri(null, FULL_URI, 10, C.LENGTH_UNSET); - RangedUri expected = new RangedUri(null, FULL_URI, 0, C.LENGTH_UNSET); - assertMerge(rangeA, rangeB, expected); + RangedUri rangeA = new RangedUri(FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(FULL_URI, 10, C.LENGTH_UNSET); + RangedUri expected = new RangedUri(FULL_URI, 0, C.LENGTH_UNSET); + assertMerge(rangeA, rangeB, expected, null); } public void testNonMerge() { // A and B do not overlap, so should not merge - RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); - RangedUri rangeB = new RangedUri(null, FULL_URI, 11, 10); - assertNonMerge(rangeA, rangeB); + RangedUri rangeA = new RangedUri(FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(FULL_URI, 11, 10); + assertNonMerge(rangeA, rangeB, null); // A and B do not overlap, so should not merge - rangeA = new RangedUri(null, FULL_URI, 0, 10); - rangeB = new RangedUri(null, FULL_URI, 11, C.LENGTH_UNSET); - assertNonMerge(rangeA, rangeB); + rangeA = new RangedUri(FULL_URI, 0, 10); + rangeB = new RangedUri(FULL_URI, 11, C.LENGTH_UNSET); + assertNonMerge(rangeA, rangeB, null); // A and B are bounded but overlap, so should not merge - rangeA = new RangedUri(null, FULL_URI, 0, 11); - rangeB = new RangedUri(null, FULL_URI, 10, 10); - assertNonMerge(rangeA, rangeB); + rangeA = new RangedUri(FULL_URI, 0, 11); + rangeB = new RangedUri(FULL_URI, 10, 10); + assertNonMerge(rangeA, rangeB, null); // A and B overlap due to unboundedness, so should not merge - rangeA = new RangedUri(null, FULL_URI, 0, C.LENGTH_UNSET); - rangeB = new RangedUri(null, FULL_URI, 10, C.LENGTH_UNSET); - assertNonMerge(rangeA, rangeB); - + rangeA = new RangedUri(FULL_URI, 0, C.LENGTH_UNSET); + rangeB = new RangedUri(FULL_URI, 10, C.LENGTH_UNSET); + assertNonMerge(rangeA, rangeB, null); } - private void assertMerge(RangedUri rangeA, RangedUri rangeB, RangedUri expected) { - RangedUri merged = rangeA.attemptMerge(rangeB); + public void testMergeWithBaseUri() { + RangedUri rangeA = new RangedUri(PARTIAL_URI, 0, 10); + RangedUri rangeB = new RangedUri(FULL_URI, 10, 10); + RangedUri expected = new RangedUri(FULL_URI, 0, 20); + assertMerge(rangeA, rangeB, expected, BASE_URI); + } + + private void assertMerge(RangedUri rangeA, RangedUri rangeB, RangedUri expected, String baseUrl) { + RangedUri merged = rangeA.attemptMerge(rangeB, baseUrl); assertEquals(expected, merged); - merged = rangeB.attemptMerge(rangeA); + merged = rangeB.attemptMerge(rangeA, baseUrl); assertEquals(expected, merged); } - private void assertNonMerge(RangedUri rangeA, RangedUri rangeB) { - RangedUri merged = rangeA.attemptMerge(rangeB); + private void assertNonMerge(RangedUri rangeA, RangedUri rangeB, String baseUrl) { + RangedUri merged = rangeA.attemptMerge(rangeB, baseUrl); assertNull(merged); - merged = rangeB.attemptMerge(rangeA); + merged = rangeB.attemptMerge(rangeA, baseUrl); assertNull(merged); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java index 681969ffa2..008cd0e556 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java @@ -27,16 +27,17 @@ public class RepresentationTest extends TestCase { public void testGetCacheKey() { String uri = "http://www.google.com"; - SegmentBase base = new SingleSegmentBase(new RangedUri(uri, null, 0, 1), 1, 0, uri, 1, 1); + SegmentBase base = new SingleSegmentBase(new RangedUri(null, 0, 1), 1, 0, 1, 1); Format format = Format.createVideoContainerFormat("0", MimeTypes.APPLICATION_MP4, null, MimeTypes.VIDEO_H264, 2500000, 1920, 1080, Format.NO_VALUE, null); - Representation representation = Representation.newInstance("test_stream_1", 3, format, base); + Representation representation = Representation.newInstance("test_stream_1", 3, format, uri, + base); assertEquals("test_stream_1.0.3", representation.getCacheKey()); format = Format.createVideoContainerFormat("150", MimeTypes.APPLICATION_MP4, null, MimeTypes.VIDEO_H264, 2500000, 1920, 1080, Format.NO_VALUE, null); representation = Representation.newInstance("test_stream_1", Representation.REVISION_ID_DEFAULT, - format, base); + format, uri, base); assertEquals("test_stream_1.150.-1", representation.getCacheKey()); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java index cd44a283a2..67ec907d61 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java @@ -72,15 +72,14 @@ public class HlsMediaPlaylistParserTest extends TestCase { HlsMediaPlaylist mediaPlaylist = (HlsMediaPlaylist) playlist; assertEquals(2679, mediaPlaylist.mediaSequence); - assertEquals(8, mediaPlaylist.targetDurationSecs); assertEquals(3, mediaPlaylist.version); - assertEquals(false, mediaPlaylist.live); + assertEquals(true, mediaPlaylist.hasEndTag); List segments = mediaPlaylist.segments; assertNotNull(segments); assertEquals(5, segments.size()); assertEquals(4, segments.get(0).discontinuitySequenceNumber); - assertEquals(7.975, segments.get(0).durationSecs); + assertEquals(7975000, segments.get(0).durationUs); assertEquals(false, segments.get(0).isEncrypted); assertEquals(null, segments.get(0).encryptionKeyUri); assertEquals(null, segments.get(0).encryptionIV); @@ -89,7 +88,7 @@ public class HlsMediaPlaylistParserTest extends TestCase { assertEquals("https://priv.example.com/fileSequence2679.ts", segments.get(0).url); assertEquals(4, segments.get(1).discontinuitySequenceNumber); - assertEquals(7.975, segments.get(1).durationSecs); + assertEquals(7975000, segments.get(1).durationUs); assertEquals(true, segments.get(1).isEncrypted); assertEquals("https://priv.example.com/key.php?r=2680", segments.get(1).encryptionKeyUri); assertEquals("0x1566B", segments.get(1).encryptionIV); @@ -98,7 +97,7 @@ public class HlsMediaPlaylistParserTest extends TestCase { assertEquals("https://priv.example.com/fileSequence2680.ts", segments.get(1).url); assertEquals(4, segments.get(2).discontinuitySequenceNumber); - assertEquals(7.941, segments.get(2).durationSecs); + assertEquals(7941000, segments.get(2).durationUs); assertEquals(false, segments.get(2).isEncrypted); assertEquals(null, segments.get(2).encryptionKeyUri); assertEquals(null, segments.get(2).encryptionIV); @@ -107,7 +106,7 @@ public class HlsMediaPlaylistParserTest extends TestCase { assertEquals("https://priv.example.com/fileSequence2681.ts", segments.get(2).url); assertEquals(5, segments.get(3).discontinuitySequenceNumber); - assertEquals(7.975, segments.get(3).durationSecs); + assertEquals(7975000, segments.get(3).durationUs); assertEquals(true, segments.get(3).isEncrypted); assertEquals("https://priv.example.com/key.php?r=2682", segments.get(3).encryptionKeyUri); // 0xA7A == 2682. @@ -118,7 +117,7 @@ public class HlsMediaPlaylistParserTest extends TestCase { assertEquals("https://priv.example.com/fileSequence2682.ts", segments.get(3).url); assertEquals(5, segments.get(4).discontinuitySequenceNumber); - assertEquals(7.975, segments.get(4).durationSecs); + assertEquals(7975000, segments.get(4).durationUs); assertEquals(true, segments.get(4).isEncrypted); assertEquals("https://priv.example.com/key.php?r=2682", segments.get(4).encryptionKeyUri); // 0xA7B == 2683. diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java index eca126347c..502fa9a789 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java @@ -30,6 +30,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { private static final String TYPICAL_EXTRA_BLANK_LINE = "subrip/typical_extra_blank_line"; private static final String TYPICAL_MISSING_TIMECODE = "subrip/typical_missing_timecode"; private static final String TYPICAL_MISSING_SEQUENCE = "subrip/typical_missing_sequence"; + private static final String TYPICAL_NEGATIVE_TIMESTAMPS = "subrip/typical_negative_timestamps"; private static final String NO_END_TIMECODES_FILE = "subrip/no_end_timecodes"; public void testDecodeEmpty() throws IOException { @@ -91,6 +92,15 @@ public final class SubripDecoderTest extends InstrumentationTestCase { assertTypicalCue3(subtitle, 2); } + public void testDecodeTypicalNegativeTimestamps() throws IOException { + // Parsing should succeed, parsing the third cue only. + SubripDecoder decoder = new SubripDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_NEGATIVE_TIMESTAMPS); + SubripSubtitle subtitle = decoder.decode(bytes, bytes.length); + assertEquals(2, subtitle.getEventTimeCount()); + assertTypicalCue3(subtitle, 0); + } + public void testDecodeNoEndTimecodes() throws IOException { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), NO_END_TIMECODES_FILE); diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoderTest.java index 9f52453908..a0feaea57d 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoderTest.java @@ -97,7 +97,7 @@ public final class Mp4WebvttDecoderTest extends TestCase { public void testNoCueSample() throws SubtitleDecoderException { Mp4WebvttDecoder decoder = new Mp4WebvttDecoder(); Subtitle result = decoder.decode(NO_CUE_SAMPLE, NO_CUE_SAMPLE.length); - assertMp4WebvttSubtitleEquals(result, new Cue[0]); + assertMp4WebvttSubtitleEquals(result); } // Negative tests. diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index 013e82bd85..6ed0518e3c 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -116,7 +116,7 @@ public class WebvttDecoderTest extends InstrumentationTestCase { Alignment.ALIGN_CENTER, 0.45f, Cue.LINE_TYPE_FRACTION, Cue.ANCHOR_TYPE_END, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, 0.35f); assertCue(subtitle, 6, 6000000, 7000000, "This is the fourth subtitle.", - Alignment.ALIGN_CENTER, -10f, Cue.LINE_TYPE_NUMBER, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, + Alignment.ALIGN_CENTER, -11f, Cue.LINE_TYPE_NUMBER, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET); assertCue(subtitle, 8, 7000000, 8000000, "This is the fifth subtitle.", Alignment.ALIGN_OPPOSITE, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f, diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/DataSourceInputStreamTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/DataSourceInputStreamTest.java new file mode 100644 index 0000000000..3200e9d6a3 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/DataSourceInputStreamTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream; + +import android.test.MoreAsserts; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.IOException; +import java.util.Arrays; +import junit.framework.TestCase; + +/** + * Unit tests for {@link DataSourceInputStream}. + */ +public class DataSourceInputStreamTest extends TestCase { + + private static final byte[] TEST_DATA = TestUtil.buildTestData(16); + + public void testReadSingleBytes() throws IOException { + DataSourceInputStream inputStream = buildTestInputStream(); + // No bytes read yet. + assertEquals(0, inputStream.bytesRead()); + // Read bytes. + for (int i = 0; i < TEST_DATA.length; i++) { + int readByte = inputStream.read(); + assertTrue(0 <= readByte && readByte < 256); + assertEquals(TEST_DATA[i] & 0xFF, readByte); + assertEquals(i + 1, inputStream.bytesRead()); + } + // Check end of stream. + assertEquals(-1, inputStream.read()); + assertEquals(TEST_DATA.length, inputStream.bytesRead()); + // Check close succeeds. + inputStream.close(); + } + + public void testRead() throws IOException { + DataSourceInputStream inputStream = buildTestInputStream(); + // Read bytes. + byte[] readBytes = new byte[TEST_DATA.length]; + int totalBytesRead = 0; + while (totalBytesRead < TEST_DATA.length) { + long bytesRead = inputStream.read(readBytes, totalBytesRead, + TEST_DATA.length - totalBytesRead); + assertTrue(bytesRead > 0); + totalBytesRead += bytesRead; + assertEquals(totalBytesRead, inputStream.bytesRead()); + } + // Check the read data. + MoreAsserts.assertEquals(TEST_DATA, readBytes); + // Check end of stream. + assertEquals(TEST_DATA.length, inputStream.bytesRead()); + assertEquals(TEST_DATA.length, totalBytesRead); + assertEquals(-1, inputStream.read()); + // Check close succeeds. + inputStream.close(); + } + + public void testSkip() throws IOException { + DataSourceInputStream inputStream = buildTestInputStream(); + // Skip bytes. + long totalBytesSkipped = 0; + while (totalBytesSkipped < TEST_DATA.length) { + long bytesSkipped = inputStream.skip(Long.MAX_VALUE); + assertTrue(bytesSkipped > 0); + totalBytesSkipped += bytesSkipped; + assertEquals(totalBytesSkipped, inputStream.bytesRead()); + } + // Check end of stream. + assertEquals(TEST_DATA.length, inputStream.bytesRead()); + assertEquals(TEST_DATA.length, totalBytesSkipped); + assertEquals(-1, inputStream.read()); + // Check close succeeds. + inputStream.close(); + } + + private static DataSourceInputStream buildTestInputStream() { + FakeDataSource.Builder fakeDataSourceBuilder = new FakeDataSource.Builder() + .appendReadData(Arrays.copyOfRange(TEST_DATA, 0, 5)) + .appendReadData(Arrays.copyOfRange(TEST_DATA, 5, 10)) + .appendReadData(Arrays.copyOfRange(TEST_DATA, 10, 15)) + .appendReadData(Arrays.copyOfRange(TEST_DATA, 15, TEST_DATA.length)); + return new DataSourceInputStream(fakeDataSourceBuilder.build(), new DataSpec(null)); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 5e85ad4d4c..18e39be93c 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -23,7 +23,6 @@ import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.testutil.FakeDataSource.Builder; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DataSpec; - import java.io.File; import java.io.IOException; import java.util.Arrays; @@ -41,11 +40,7 @@ public class CacheDataSourceTest extends InstrumentationTestCase { @Override protected void setUp() throws Exception { - // Create a temporary folder - cacheDir = File.createTempFile("CacheDataSourceTest", null); - assertTrue(cacheDir.delete()); - assertTrue(cacheDir.mkdir()); - + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); } @@ -57,8 +52,12 @@ public class CacheDataSourceTest extends InstrumentationTestCase { public void testMaxCacheFileSize() throws Exception { CacheDataSource cacheDataSource = createCacheDataSource(false, false); assertReadDataContentLength(cacheDataSource, false, false); - assertEquals((int) Math.ceil((double) TEST_DATA.length / MAX_CACHE_FILE_SIZE), - cacheDir.listFiles().length); + File[] files = cacheDir.listFiles(); + for (File file : files) { + if (!file.getName().equals(CachedContentIndex.FILE_NAME)) { + assertTrue(file.length() <= MAX_CACHE_FILE_SIZE); + } + } } public void testCacheAndRead() throws Exception { @@ -177,8 +176,7 @@ public class CacheDataSourceTest extends InstrumentationTestCase { builder.setSimulateUnknownLength(simulateUnknownLength); builder.appendReadData(TEST_DATA); FakeDataSource upstream = builder.build(); - return new CacheDataSource(simpleCache, upstream, - CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_CACHE_UNBOUNDED_REQUESTS, + return new CacheDataSource(simpleCache, upstream, CacheDataSource.FLAG_BLOCK_ON_CACHE, MAX_CACHE_FILE_SIZE); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheSpanTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheSpanTest.java deleted file mode 100644 index 38008c814e..0000000000 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheSpanTest.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.upstream.cache; - -import com.google.android.exoplayer2.testutil.TestUtil; -import java.io.File; -import java.util.Random; -import junit.framework.TestCase; - -/** - * Unit tests for {@link CacheSpan}. - */ -public class CacheSpanTest extends TestCase { - - public void testCacheFile() throws Exception { - assertCacheSpan(new File("parent"), "key", 0, 0); - assertCacheSpan(new File("parent/"), "key", 1, 2); - assertCacheSpan(new File("parent"), "<>:\"/\\|?*%", 1, 2); - assertCacheSpan(new File("/"), "key", 1, 2); - - assertNullCacheSpan(new File("parent"), "", 1, 2); - assertNullCacheSpan(new File("parent"), "key", -1, 2); - assertNullCacheSpan(new File("parent"), "key", 1, -2); - - assertNotNull(CacheSpan.createCacheEntry(new File("/asd%aa.1.2.v2.exo"))); - assertNull(CacheSpan.createCacheEntry(new File("/asd%za.1.2.v2.exo"))); - - assertCacheSpan(new File("parent"), - "A newline (line feed) character \n" - + "A carriage-return character followed immediately by a newline character \r\n" - + "A standalone carriage-return character \r" - + "A next-line character \u0085" - + "A line-separator character \u2028" - + "A paragraph-separator character \u2029", 1, 2); - } - - public void testCacheFileNameRandomData() throws Exception { - Random random = new Random(0); - File parent = new File("parent"); - for (int i = 0; i < 1000; i++) { - String key = TestUtil.buildTestString(1000, random); - long offset = Math.abs(random.nextLong()); - long lastAccessTimestamp = Math.abs(random.nextLong()); - assertCacheSpan(parent, key, offset, lastAccessTimestamp); - } - } - - private void assertCacheSpan(File parent, String key, long offset, long lastAccessTimestamp) { - File cacheFile = CacheSpan.getCacheFileName(parent, key, offset, lastAccessTimestamp); - CacheSpan cacheSpan = CacheSpan.createCacheEntry(cacheFile); - String message = cacheFile.toString(); - assertNotNull(message, cacheSpan); - assertEquals(message, parent, cacheFile.getParentFile()); - assertEquals(message, key, cacheSpan.key); - assertEquals(message, offset, cacheSpan.position); - assertEquals(message, lastAccessTimestamp, cacheSpan.lastAccessTimestamp); - } - - private void assertNullCacheSpan(File parent, String key, long offset, - long lastAccessTimestamp) { - File cacheFile = CacheSpan.getCacheFileName(parent, key, offset, lastAccessTimestamp); - CacheSpan cacheSpan = CacheSpan.createCacheEntry(cacheFile); - assertNull(cacheFile.toString(), cacheSpan); - } - -} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java new file mode 100644 index 0000000000..dd4de7cce2 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java @@ -0,0 +1,226 @@ +package com.google.android.exoplayer2.upstream.cache; + +import android.test.InstrumentationTestCase; +import android.test.MoreAsserts; +import android.util.SparseArray; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import junit.framework.AssertionFailedError; + +/** + * Tests {@link CachedContentIndex}. + */ +public class CachedContentIndexTest extends InstrumentationTestCase { + + private final byte[] testIndexV1File = { + 0, 0, 0, 1, // version + 0, 0, 0, 0, // flags + 0, 0, 0, 2, // number_of_CachedContent + 0, 0, 0, 5, // cache_id + 0, 5, 65, 66, 67, 68, 69, // cache_key + 0, 0, 0, 0, 0, 0, 0, 10, // original_content_length + 0, 0, 0, 2, // cache_id + 0, 5, 75, 76, 77, 78, 79, // cache_key + 0, 0, 0, 0, 0, 0, 10, 0, // original_content_length + (byte) 0xF6, (byte) 0xFB, 0x50, 0x41 // hashcode_of_CachedContent_array + }; + private CachedContentIndex index; + private File cacheDir; + + @Override + public void setUp() throws Exception { + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); + index = new CachedContentIndex(cacheDir); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(cacheDir); + } + + public void testAddGetRemove() throws Exception { + final String key1 = "key1"; + final String key2 = "key2"; + final String key3 = "key3"; + + // Add two CachedContents with add methods + CachedContent cachedContent1 = new CachedContent(5, key1, 10); + index.addNew(cachedContent1); + CachedContent cachedContent2 = index.add(key2); + assertTrue(cachedContent1.id != cachedContent2.id); + + // add a span + File cacheSpanFile = SimpleCacheSpanTest + .createCacheSpanFile(cacheDir, cachedContent1.id, 10, 20, 30); + SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(cacheSpanFile, index); + assertNotNull(span); + cachedContent1.addSpan(span); + + // Check if they are added and get method returns null if the key isn't found + assertEquals(cachedContent1, index.get(key1)); + assertEquals(cachedContent2, index.get(key2)); + assertNull(index.get(key3)); + + // test getAll() + Collection cachedContents = index.getAll(); + assertEquals(2, cachedContents.size()); + assertTrue(Arrays.asList(cachedContent1, cachedContent2).containsAll(cachedContents)); + + // test getKeys() + Set keys = index.getKeys(); + assertEquals(2, keys.size()); + assertTrue(Arrays.asList(key1, key2).containsAll(keys)); + + // test getKeyForId() + assertEquals(key1, index.getKeyForId(cachedContent1.id)); + assertEquals(key2, index.getKeyForId(cachedContent2.id)); + + // test remove() + index.removeEmpty(key2); + index.removeEmpty(key3); + assertEquals(cachedContent1, index.get(key1)); + assertNull(index.get(key2)); + assertTrue(cacheSpanFile.exists()); + + // test removeEmpty() + index.addNew(cachedContent2); + index.removeEmpty(); + assertEquals(cachedContent1, index.get(key1)); + assertNull(index.get(key2)); + assertTrue(cacheSpanFile.exists()); + } + + public void testStoreAndLoad() throws Exception { + assertStoredAndLoadedEqual(index, new CachedContentIndex(cacheDir)); + } + + public void testLoadV1() throws Exception { + FileOutputStream fos = new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME)); + fos.write(testIndexV1File); + fos.close(); + + index.load(); + assertEquals(2, index.getAll().size()); + assertEquals(5, index.assignIdForKey("ABCDE")); + assertEquals(10, index.getContentLength("ABCDE")); + assertEquals(2, index.assignIdForKey("KLMNO")); + assertEquals(2560, index.getContentLength("KLMNO")); + } + + public void testStoreV1() throws Exception { + index.addNew(new CachedContent(2, "KLMNO", 2560)); + index.addNew(new CachedContent(5, "ABCDE", 10)); + + index.store(); + + byte[] buffer = new byte[testIndexV1File.length]; + FileInputStream fos = new FileInputStream(new File(cacheDir, CachedContentIndex.FILE_NAME)); + assertEquals(testIndexV1File.length, fos.read(buffer)); + assertEquals(-1, fos.read()); + fos.close(); + + // TODO: The order of the CachedContent stored in index file isn't defined so this test may fail + // on a different implementation of the underlying set + MoreAsserts.assertEquals(testIndexV1File, buffer); + } + + public void testAssignIdForKeyAndGetKeyForId() throws Exception { + final String key1 = "key1"; + final String key2 = "key2"; + int id1 = index.assignIdForKey(key1); + int id2 = index.assignIdForKey(key2); + assertEquals(key1, index.getKeyForId(id1)); + assertEquals(key2, index.getKeyForId(id2)); + assertTrue(id1 != id2); + assertEquals(id1, index.assignIdForKey(key1)); + assertEquals(id2, index.assignIdForKey(key2)); + } + + public void testSetGetContentLength() throws Exception { + final String key1 = "key1"; + assertEquals(C.LENGTH_UNSET, index.getContentLength(key1)); + index.setContentLength(key1, 10); + assertEquals(10, index.getContentLength(key1)); + } + + public void testGetNewId() throws Exception { + SparseArray idToKey = new SparseArray<>(); + assertEquals(0, CachedContentIndex.getNewId(idToKey)); + idToKey.put(10, ""); + assertEquals(11, CachedContentIndex.getNewId(idToKey)); + idToKey.put(Integer.MAX_VALUE, ""); + assertEquals(0, CachedContentIndex.getNewId(idToKey)); + idToKey.put(0, ""); + assertEquals(1, CachedContentIndex.getNewId(idToKey)); + } + + public void testEncryption() throws Exception { + byte[] key = "Bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + byte[] key2 = "bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key)); + + // Rename the index file from the test above + File file1 = new File(cacheDir, CachedContentIndex.FILE_NAME); + File file2 = new File(cacheDir, "file2compare"); + assertTrue(file1.renameTo(file2)); + + // Write a new index file + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key)); + + assertEquals(file2.length(), file1.length()); + // Assert file content is different + FileInputStream fis1 = new FileInputStream(file1); + FileInputStream fis2 = new FileInputStream(file2); + for (int b; (b = fis1.read()) == fis2.read();) { + assertTrue(b != -1); + } + + boolean threw = false; + try { + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key2)); + } catch (AssertionFailedError e) { + threw = true; + } + assertTrue("Encrypted index file can not be read with different encryption key", threw); + + try { + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir)); + } catch (AssertionFailedError e) { + threw = true; + } + assertTrue("Encrypted index file can not be read without encryption key", threw); + + // Non encrypted index file can be read even when encryption key provided. + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir), + new CachedContentIndex(cacheDir, key)); + } + + private void assertStoredAndLoadedEqual(CachedContentIndex index, CachedContentIndex index2) + throws IOException { + index.addNew(new CachedContent(5, "key1", 10)); + index.add("key2"); + index.store(); + + index2.load(); + Set keys = index.getKeys(); + Set keys2 = index2.getKeys(); + assertEquals(keys, keys2); + for (String key : keys) { + assertEquals(index.getContentLength(key), index2.getContentLength(key)); + assertEquals(index.get(key).getSpans(), index2.get(key).getSpans()); + } + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpanTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpanTest.java new file mode 100644 index 0000000000..0b40cd7735 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpanTest.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Set; +import java.util.TreeSet; + +/** + * Unit tests for {@link SimpleCacheSpan}. + */ +public class SimpleCacheSpanTest extends InstrumentationTestCase { + + private CachedContentIndex index; + private File cacheDir; + + public static File createCacheSpanFile(File cacheDir, int id, long offset, int length, + long lastAccessTimestamp) throws IOException { + File cacheFile = SimpleCacheSpan.getCacheFile(cacheDir, id, offset, lastAccessTimestamp); + createTestFile(cacheFile, length); + return cacheFile; + } + + public static CacheSpan createCacheSpan(CachedContentIndex index, File cacheDir, String key, + long offset, int length, long lastAccessTimestamp) throws IOException { + int id = index.assignIdForKey(key); + File cacheFile = createCacheSpanFile(cacheDir, id, offset, length, lastAccessTimestamp); + return SimpleCacheSpan.createCacheEntry(cacheFile, index); + } + + @Override + protected void setUp() throws Exception { + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); + index = new CachedContentIndex(cacheDir); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(cacheDir); + } + + public void testCacheFile() throws Exception { + assertCacheSpan("key1", 0, 0); + assertCacheSpan("key2", 1, 2); + assertCacheSpan("<>:\"/\\|?*%", 1, 2); + assertCacheSpan("key3", 1, 2); + + assertNullCacheSpan(new File("parent"), "key4", -1, 2); + assertNullCacheSpan(new File("parent"), "key5", 1, -2); + + assertCacheSpan( + "A newline (line feed) character \n" + + "A carriage-return character followed immediately by a newline character \r\n" + + "A standalone carriage-return character \r" + + "A next-line character \u0085" + + "A line-separator character \u2028" + + "A paragraph-separator character \u2029", 1, 2); + } + + public void testUpgradeFileName() throws Exception { + String key = "asd\u00aa"; + int id = index.assignIdForKey(key); + File v3file = createTestFile(id + ".0.1.v3.exo"); + File v2file = createTestFile("asd%aa.1.2.v2.exo"); + File wrongEscapedV2file = createTestFile("asd%za.3.4.v2.exo"); + File v1File = createTestFile("asd\u00aa.5.6.v1.exo"); + + for (File file : cacheDir.listFiles()) { + SimpleCacheSpan cacheEntry = SimpleCacheSpan.createCacheEntry(file, index); + if (file.equals(wrongEscapedV2file)) { + assertNull(cacheEntry); + } else { + assertNotNull(cacheEntry); + } + } + + assertTrue(v3file.exists()); + assertFalse(v2file.exists()); + assertTrue(wrongEscapedV2file.exists()); + assertFalse(v1File.exists()); + + File[] files = cacheDir.listFiles(); + assertEquals(4, files.length); + + Set keys = index.getKeys(); + assertEquals("There should be only one key for all files.", 1, keys.size()); + assertTrue(keys.contains(key)); + + TreeSet spans = index.get(key).getSpans(); + assertTrue("upgradeOldFiles() shouldn't add any spans.", spans.isEmpty()); + + HashMap cachedPositions = new HashMap<>(); + for (File file : files) { + SimpleCacheSpan cacheSpan = SimpleCacheSpan.createCacheEntry(file, index); + if (cacheSpan != null) { + assertEquals(key, cacheSpan.key); + cachedPositions.put(cacheSpan.position, cacheSpan.lastAccessTimestamp); + } + } + + assertEquals(1, (long) cachedPositions.get((long) 0)); + assertEquals(2, (long) cachedPositions.get((long) 1)); + assertEquals(6, (long) cachedPositions.get((long) 5)); + } + + private static void createTestFile(File file, int length) throws IOException { + FileOutputStream output = new FileOutputStream(file); + for (int i = 0; i < length; i++) { + output.write(i); + } + output.close(); + } + + private File createTestFile(String name) throws IOException { + File file = new File(cacheDir, name); + createTestFile(file, 1); + return file; + } + + private void assertCacheSpan(String key, long offset, long lastAccessTimestamp) + throws IOException { + int id = index.assignIdForKey(key); + File cacheFile = createCacheSpanFile(cacheDir, id, offset, 1, lastAccessTimestamp); + SimpleCacheSpan cacheSpan = SimpleCacheSpan.createCacheEntry(cacheFile, index); + String message = cacheFile.toString(); + assertNotNull(message, cacheSpan); + assertEquals(message, cacheDir, cacheFile.getParentFile()); + assertEquals(message, key, cacheSpan.key); + assertEquals(message, offset, cacheSpan.position); + assertEquals(message, 1, cacheSpan.length); + assertTrue(message, cacheSpan.isCached); + assertEquals(message, cacheFile, cacheSpan.file); + assertEquals(message, lastAccessTimestamp, cacheSpan.lastAccessTimestamp); + } + + private void assertNullCacheSpan(File parent, String key, long offset, + long lastAccessTimestamp) { + File cacheFile = SimpleCacheSpan.getCacheFile(parent, index.assignIdForKey(key), offset, + lastAccessTimestamp); + CacheSpan cacheSpan = SimpleCacheSpan.createCacheEntry(cacheFile, index); + assertNull(cacheFile.toString(), cacheSpan); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 2c8ea912fb..5f539c6213 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.upstream.cache; import android.test.InstrumentationTestCase; - import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TestUtil; import java.io.File; @@ -36,10 +35,7 @@ public class SimpleCacheTest extends InstrumentationTestCase { @Override protected void setUp() throws Exception { - // Create a temporary folder - cacheDir = File.createTempFile("SimpleCacheTest", null); - assertTrue(cacheDir.delete()); - assertTrue(cacheDir.mkdir()); + this.cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); } @Override @@ -48,7 +44,7 @@ public class SimpleCacheTest extends InstrumentationTestCase { } public void testCommittingOneFile() throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + SimpleCache simpleCache = getSimpleCache(); CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); assertFalse(cacheSpan.isCached); @@ -79,37 +75,40 @@ public class SimpleCacheTest extends InstrumentationTestCase { } public void testSetGetLength() throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + SimpleCache simpleCache = getSimpleCache(); assertEquals(C.LENGTH_UNSET, simpleCache.getContentLength(KEY_1)); - assertTrue(simpleCache.setContentLength(KEY_1, 15)); + simpleCache.setContentLength(KEY_1, 15); assertEquals(15, simpleCache.getContentLength(KEY_1)); simpleCache.startReadWrite(KEY_1, 0); addCache(simpleCache, 0, 15); - assertTrue(simpleCache.setContentLength(KEY_1, 150)); + simpleCache.setContentLength(KEY_1, 150); assertEquals(150, simpleCache.getContentLength(KEY_1)); addCache(simpleCache, 140, 10); - // Try to set length shorter then the content - assertFalse(simpleCache.setContentLength(KEY_1, 15)); - assertEquals("Content length should be unchanged.", - 150, simpleCache.getContentLength(KEY_1)); - - /* TODO Enable when the length persistance is fixed // Check if values are kept after cache is reloaded. - simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - assertEquals(150, simpleCache.getContentLength(KEY_1)); - CacheSpan lastSpan = simpleCache.startReadWrite(KEY_1, 145); + SimpleCache simpleCache2 = getSimpleCache(); + Set keys = simpleCache.getKeys(); + Set keys2 = simpleCache2.getKeys(); + assertEquals(keys, keys2); + for (String key : keys) { + assertEquals(simpleCache.getContentLength(key), simpleCache2.getContentLength(key)); + assertEquals(simpleCache.getCachedSpans(key), simpleCache2.getCachedSpans(key)); + } // Removing the last span shouldn't cause the length be change next time cache loaded - simpleCache.removeSpan(lastSpan); - simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - assertEquals(150, simpleCache.getContentLength(KEY_1)); - */ + SimpleCacheSpan lastSpan = simpleCache2.startReadWrite(KEY_1, 145); + simpleCache2.removeSpan(lastSpan); + simpleCache2 = getSimpleCache(); + assertEquals(150, simpleCache2.getContentLength(KEY_1)); + } + + private SimpleCache getSimpleCache() { + return new SimpleCache(cacheDir, new NoOpCacheEvictor()); } private void addCache(SimpleCache simpleCache, int position, int length) throws IOException { diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java new file mode 100644 index 0000000000..7cdbb9a5b1 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.util; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Tests {@link AtomicFile}. + */ +public class AtomicFileTest extends InstrumentationTestCase { + + private File tempFolder; + private File file; + private AtomicFile atomicFile; + + @Override + public void setUp() throws Exception { + tempFolder = TestUtil.createTempFolder(getInstrumentation().getContext()); + file = new File(tempFolder, "atomicFile"); + atomicFile = new AtomicFile(file); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(tempFolder); + } + + public void testDelete() throws Exception { + assertTrue(file.createNewFile()); + atomicFile.delete(); + assertFalse(file.exists()); + } + + public void testWriteRead() throws Exception { + OutputStream output = atomicFile.startWrite(); + output.write(5); + atomicFile.endWrite(output); + output.close(); + + assertRead(); + + output = atomicFile.startWrite(); + output.write(5); + output.write(6); + output.close(); + + assertRead(); + + output = atomicFile.startWrite(); + output.write(6); + + assertRead(); + output.close(); + + output = atomicFile.startWrite(); + + assertRead(); + output.close(); + } + + private void assertRead() throws IOException { + InputStream input = atomicFile.openRead(); + assertEquals(5, input.read()); + assertEquals(-1, input.read()); + input.close(); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStreamTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStreamTest.java new file mode 100644 index 0000000000..beb9e44853 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStreamTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import android.test.MoreAsserts; +import java.io.ByteArrayOutputStream; +import junit.framework.TestCase; + +/** + * Tests {@link ReusableBufferedOutputStream}. + */ +public class ReusableBufferedOutputStreamTest extends TestCase { + + private static final byte[] TEST_DATA_1 = "test data 1".getBytes(); + private static final byte[] TEST_DATA_2 = "2 test data".getBytes(); + + public void testReset() throws Exception { + ByteArrayOutputStream byteArrayOutputStream1 = new ByteArrayOutputStream(1000); + ReusableBufferedOutputStream outputStream = new ReusableBufferedOutputStream( + byteArrayOutputStream1, 1000); + outputStream.write(TEST_DATA_1); + outputStream.close(); + + ByteArrayOutputStream byteArrayOutputStream2 = new ByteArrayOutputStream(1000); + outputStream.reset(byteArrayOutputStream2); + outputStream.write(TEST_DATA_2); + outputStream.close(); + + MoreAsserts.assertEquals(TEST_DATA_1, byteArrayOutputStream1.toByteArray()); + MoreAsserts.assertEquals(TEST_DATA_2, byteArrayOutputStream2.toByteArray()); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/util/UtilTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/util/UtilTest.java index 8d74379093..35e168e514 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/util/UtilTest.java @@ -15,9 +15,7 @@ */ package com.google.android.exoplayer2.util; -import android.test.MoreAsserts; import com.google.android.exoplayer2.testutil.TestUtil; -import java.text.ParseException; import java.util.ArrayList; import java.util.List; import java.util.Random; @@ -141,23 +139,11 @@ public class UtilTest extends TestCase { assertEquals(1500L, Util.parseXsDuration("PT1.500S")); } - public void testParseXsDateTime() throws ParseException { + public void testParseXsDateTime() throws Exception { assertEquals(1403219262000L, Util.parseXsDateTime("2014-06-19T23:07:42")); assertEquals(1407322800000L, Util.parseXsDateTime("2014-08-06T11:00:00Z")); - } - - public void testGetHexStringByteArray() throws Exception { - assertHexStringByteArray("", new byte[] {}); - assertHexStringByteArray("01", new byte[] {1}); - assertHexStringByteArray("FF", new byte[] {(byte) 255}); - assertHexStringByteArray("01020304", new byte[] {1, 2, 3, 4}); - assertHexStringByteArray("0123456789ABCDEF", - new byte[] {1, 0x23, 0x45, 0x67, (byte) 0x89, (byte) 0xAB, (byte) 0xCD, (byte) 0xEF}); - } - - private void assertHexStringByteArray(String hex, byte[] array) { - assertEquals(hex, Util.getHexString(array)); - MoreAsserts.assertEquals(array, Util.getBytesFromHexString(hex)); + assertEquals(1411161535000L, Util.parseXsDateTime("2014-09-19T13:18:55-08:00")); + assertEquals(1411161535000L, Util.parseXsDateTime("2014-09-19T13:18:55-0800")); } public void testUnescapeInvalidFileName() { diff --git a/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index 3763988978..447e39bf52 100644 --- a/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -70,8 +70,8 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void enable(Format[] formats, SampleStream stream, long positionUs, - boolean joining, long offsetUs) throws ExoPlaybackException { + public final void enable(Format[] formats, SampleStream stream, long positionUs, boolean joining, + long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); state = STATE_ENABLED; onEnabled(joining); @@ -107,10 +107,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void setCurrentStreamIsFinal() { + public final void setCurrentStreamFinal() { streamIsFinal = true; } + @Override + public final boolean isCurrentStreamFinal() { + return streamIsFinal; + } + @Override public final void maybeThrowStreamError() throws IOException { stream.maybeThrowError(); @@ -119,6 +124,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @Override public final void resetPosition(long positionUs) throws ExoPlaybackException { streamIsFinal = false; + readEndOfStream = false; onPositionReset(positionUs, false); } @@ -194,8 +200,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * @param joining Whether this renderer is being enabled to join an ongoing playback. * @throws ExoPlaybackException If an error occurs. */ - protected void onPositionReset(long positionUs, boolean joining) - throws ExoPlaybackException { + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { // Do nothing. } @@ -243,7 +248,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { /** * Reads from the enabled upstream source. If the upstream source has been read to the end then - * {@link C#RESULT_BUFFER_READ} is only returned if {@link #setCurrentStreamIsFinal()} has been + * {@link C#RESULT_BUFFER_READ} is only returned if {@link #setCurrentStreamFinal()} has been * called. {@link C#RESULT_NOTHING_READ} is returned otherwise. * * @see SampleStream#readData(FormatHolder, DecoderInputBuffer) diff --git a/library/src/main/java/com/google/android/exoplayer2/C.java b/library/src/main/java/com/google/android/exoplayer2/C.java index 8c69524e95..3e6fac4a5e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/src/main/java/com/google/android/exoplayer2/C.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import android.media.AudioFormat; +import android.media.AudioManager; import android.media.MediaCodec; import android.support.annotation.IntDef; import android.view.Surface; @@ -159,6 +160,42 @@ public final class C { public static final int CHANNEL_OUT_7POINT1_SURROUND = Util.SDK_INT < 23 ? AudioFormat.CHANNEL_OUT_7POINT1 : AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; + /** + * Stream types for an {@link android.media.AudioTrack}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({STREAM_TYPE_ALARM, STREAM_TYPE_MUSIC, STREAM_TYPE_NOTIFICATION, STREAM_TYPE_RING, + STREAM_TYPE_SYSTEM, STREAM_TYPE_VOICE_CALL}) + public @interface StreamType {} + /** + * @see AudioManager#STREAM_ALARM + */ + public static final int STREAM_TYPE_ALARM = AudioManager.STREAM_ALARM; + /** + * @see AudioManager#STREAM_MUSIC + */ + public static final int STREAM_TYPE_MUSIC = AudioManager.STREAM_MUSIC; + /** + * @see AudioManager#STREAM_NOTIFICATION + */ + public static final int STREAM_TYPE_NOTIFICATION = AudioManager.STREAM_NOTIFICATION; + /** + * @see AudioManager#STREAM_RING + */ + public static final int STREAM_TYPE_RING = AudioManager.STREAM_RING; + /** + * @see AudioManager#STREAM_SYSTEM + */ + public static final int STREAM_TYPE_SYSTEM = AudioManager.STREAM_SYSTEM; + /** + * @see AudioManager#STREAM_VOICE_CALL + */ + public static final int STREAM_TYPE_VOICE_CALL = AudioManager.STREAM_VOICE_CALL; + /** + * The default stream type used by audio renderers. + */ + public static final int STREAM_TYPE_DEFAULT = STREAM_TYPE_MUSIC; + /** * Flags which can apply to a buffer containing a media sample. */ @@ -185,6 +222,29 @@ public final class C { */ public static final int BUFFER_FLAG_DECODE_ONLY = 0x80000000; + /** + * Video scaling modes for {@link MediaCodec}-based {@link Renderer}s. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) + public @interface VideoScalingMode {} + /** + * @see MediaCodec#VIDEO_SCALING_MODE_SCALE_TO_FIT + */ + @SuppressWarnings("InlinedApi") + public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT = + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT; + /** + * @see MediaCodec#VIDEO_SCALING_MODE_SCALE_TO_FIT + */ + @SuppressWarnings("InlinedApi") + public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING = + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING; + /** + * A default video scaling mode for {@link MediaCodec}-based {@link Renderer}s. + */ + public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; + /** * Track selection flags. */ @@ -397,21 +457,45 @@ public final class C { public static final int MSG_SET_SURFACE = 1; /** - * The type of a message that can be passed to an audio {@link Renderer} via + * A type of a message that can be passed to an audio {@link Renderer} via * {@link ExoPlayer#sendMessages} or {@link ExoPlayer#blockingSendMessages}. The message object * should be a {@link Float} with 0 being silence and 1 being unity gain. */ public static final int MSG_SET_VOLUME = 2; /** - * The type of a message that can be passed to an audio {@link Renderer} via + * A type of a message that can be passed to an audio {@link Renderer} via * {@link ExoPlayer#sendMessages} or {@link ExoPlayer#blockingSendMessages}. The message object - * should be a {@link android.media.PlaybackParams}, which will be used to configure the + * should be a {@link android.media.PlaybackParams}, or null, which will be used to configure the * underlying {@link android.media.AudioTrack}. The message object should not be modified by the * caller after it has been passed */ public static final int MSG_SET_PLAYBACK_PARAMS = 3; + /** + * A type of a message that can be passed to an audio {@link Renderer} via + * {@link ExoPlayer#sendMessages} or {@link ExoPlayer#blockingSendMessages}. The message object + * should be one of the integer stream types in {@link C.StreamType}, and will specify the stream + * type of the underlying {@link android.media.AudioTrack}. See also + * {@link android.media.AudioTrack#AudioTrack(int, int, int, int, int, int)}. If the stream type + * is not set, audio renderers use {@link #STREAM_TYPE_DEFAULT}. + *

+ * Note that when the stream type changes, the AudioTrack must be reinitialized, which can + * introduce a brief gap in audio output. Note also that tracks in the same audio session must + * share the same routing, so a new audio session id will be generated. + */ + public static final int MSG_SET_STREAM_TYPE = 4; + + /** + * The type of a message that can be passed to a {@link MediaCodec}-based video {@link Renderer} + * via {@link ExoPlayer#sendMessages} or {@link ExoPlayer#blockingSendMessages}. The message + * object should be one of the integer scaling modes in {@link C.VideoScalingMode}. + *

+ * Note that the scaling mode only applies if the {@link Surface} targeted by the renderer is + * owned by a {@link android.view.SurfaceView}. + */ + public static final int MSG_SET_SCALING_MODE = 5; + /** * Applications or extensions may define custom {@code MSG_*} constants greater than or equal to * this value. diff --git a/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java b/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java index e7a0f8b1b8..e6a39d8a27 100644 --- a/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java +++ b/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.TrackSelections; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.util.Util; @@ -111,7 +111,7 @@ public final class DefaultLoadControl implements LoadControl { @Override public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroups, - TrackSelections trackSelections) { + TrackSelectionArray trackSelections) { targetBufferSize = 0; for (int i = 0; i < renderers.length; i++) { if (trackSelections.get(i) != null) { diff --git a/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index e3c9b6e114..6c64d2c0f3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -22,11 +22,13 @@ import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; @@ -110,6 +112,28 @@ public interface ExoPlayer { */ interface EventListener { + /** + * Called when the timeline and/or manifest has been refreshed. + *

+ * Note that if the timeline has changed then a position discontinuity may also have occurred. + * For example the current period index may have changed as a result of periods being added or + * removed from the timeline. The will not be reported via a separate call to + * {@link #onPositionDiscontinuity()}. + * + * @param timeline The latest timeline. Never null, but may be empty. + * @param manifest The latest manifest. May be null. + */ + void onTimelineChanged(Timeline timeline, Object manifest); + + /** + * Called when the available or selected tracks change. + * + * @param trackGroups The available tracks. Never null, but may be of length zero. + * @param trackSelections The track selections for each {@link Renderer}. Never null and always + * of length {@link #getRendererCount()}, but may contain null elements. + */ + void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections); + /** * Called when the player starts or stops loading the source. * @@ -127,14 +151,6 @@ public interface ExoPlayer { */ void onPlayerStateChanged(boolean playWhenReady, int playbackState); - /** - * Called when timeline and/or manifest has been refreshed. - * - * @param timeline The latest timeline, or null if the timeline is being cleared. - * @param manifest The latest manifest, or null if the manifest is being cleared. - */ - void onTimelineChanged(Timeline timeline, Object manifest); - /** * Called when an error occurs. The playback state will transition to {@link #STATE_IDLE} * immediately after this method is called. The player instance can still be used, and @@ -145,9 +161,14 @@ public interface ExoPlayer { void onPlayerError(ExoPlaybackException error); /** - * Called when a position discontinuity occurs. Position discontinuities occur when seeks are - * performed, when playbacks transition from one period in the timeline to the next, and when - * the player introduces discontinuities internally. + * Called when a position discontinuity occurs without a change to the timeline. A position + * discontinuity occurs when the current window or period index changes (as a result of playback + * transitioning from one period in the timeline to the next), or when the playback position + * jumps within the period currently being played (as a result of a seek being performed, or + * when the source introduces a discontinuity internally). + *

+ * When a position discontinuity occurs as a result of a change to the timeline this method is + * not called. {@link #onTimelineChanged(Timeline, Object)} is called in this case. */ void onPositionDiscontinuity(); @@ -259,11 +280,11 @@ public interface ExoPlayer { * @param resetPosition Whether the playback position should be reset to the default position in * the first {@link Timeline.Window}. If false, playback will start from the position defined * by {@link #getCurrentWindowIndex()} and {@link #getCurrentPosition()}. - * @param resetTimeline Whether the timeline and manifest should be reset. Should be true unless - * the player is being prepared to play the same media as it was playing previously (e.g. if - * playback failed and is being retried). + * @param resetState Whether the timeline, manifest, tracks and track selections should be reset. + * Should be true unless the player is being prepared to play the same media as it was playing + * previously (e.g. if playback failed and is being retried). */ - void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetTimeline); + void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState); /** * Sets whether playback should proceed when {@link #getPlaybackState()} == {@link #STATE_READY}. @@ -309,17 +330,19 @@ public interface ExoPlayer { /** * Seeks to a position specified in milliseconds in the current window. * - * @param windowPositionMs The seek position in the current window. + * @param positionMs The seek position in the current window, or {@link C#TIME_UNSET} to seek to + * the window's default position. */ - void seekTo(long windowPositionMs); + void seekTo(long positionMs); /** * Seeks to a position specified in milliseconds in the specified window. * * @param windowIndex The index of the window. - * @param windowPositionMs The seek position in the specified window. + * @param positionMs The seek position in the specified window, or {@link C#TIME_UNSET} to seek to + * the window's default position. */ - void seekTo(int windowIndex, long windowPositionMs); + void seekTo(int windowIndex, long positionMs); /** * Stops playback. Use {@code setPlayWhenReady(false)} rather than this method if the intention @@ -356,19 +379,43 @@ public interface ExoPlayer { */ void blockingSendMessages(ExoPlayerMessage... messages); + /** + * Returns the number of renderers. + */ + int getRendererCount(); + + /** + * Returns the track type that the renderer at a given index handles. + * + * @see Renderer#getTrackType() + * @param index The index of the renderer. + * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + */ + int getRendererType(int index); + + /** + * Returns the available track groups. + */ + TrackGroupArray getCurrentTrackGroups(); + + /** + * Returns the current track selections for each renderer. + */ + TrackSelectionArray getCurrentTrackSelections(); + /** * Returns the current manifest. The type depends on the {@link MediaSource} passed to - * {@link #prepare}. + * {@link #prepare}. May be null. */ Object getCurrentManifest(); /** - * Returns the current {@link Timeline}, or {@code null} if there is no timeline. + * Returns the current {@link Timeline}. Never null, but may be empty. */ Timeline getCurrentTimeline(); /** - * Returns the index of the period currently being played, or {@link C#INDEX_UNSET} if unknown. + * Returns the index of the period currently being played. */ int getCurrentPeriodIndex(); diff --git a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index 91ab56805a..43de6fe751 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -42,14 +42,14 @@ public final class ExoPlayerFactory { * @param trackSelector The {@link TrackSelector} that will be used by the instance. * @param loadControl The {@link LoadControl} that will be used by the instance. */ - public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, + public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, LoadControl loadControl) { return newSimpleInstance(context, trackSelector, loadControl, null); } /** * Creates a {@link SimpleExoPlayer} instance. Must be called from a thread that has an associated - * {@link Looper}. + * {@link Looper}. Available extension renderers are not used. * * @param context A {@link Context}. * @param trackSelector The {@link TrackSelector} that will be used by the instance. @@ -57,9 +57,10 @@ public final class ExoPlayerFactory { * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance * will not be used for DRM protected playbacks. */ - public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, + public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, LoadControl loadControl, DrmSessionManager drmSessionManager) { - return newSimpleInstance(context, trackSelector, loadControl, drmSessionManager, false); + return newSimpleInstance(context, trackSelector, loadControl, + drmSessionManager, SimpleExoPlayer.EXTENSION_RENDERER_MODE_OFF); } /** @@ -71,15 +72,15 @@ public final class ExoPlayerFactory { * @param loadControl The {@link LoadControl} that will be used by the instance. * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance * will not be used for DRM protected playbacks. - * @param preferExtensionDecoders True to prefer {@link Renderer} instances defined in - * available extensions over those defined in the core library. Note that extensions must be - * included in the application build for setting this flag to have any effect. + * @param extensionRendererMode The extension renderer mode, which determines if and how available + * extension renderers are used. Note that extensions must be included in the application + * build for them to be considered available. */ - public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, + public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, LoadControl loadControl, DrmSessionManager drmSessionManager, - boolean preferExtensionDecoders) { + @SimpleExoPlayer.ExtensionRendererMode int extensionRendererMode) { return newSimpleInstance(context, trackSelector, loadControl, drmSessionManager, - preferExtensionDecoders, DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS); + extensionRendererMode, DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS); } /** @@ -91,17 +92,18 @@ public final class ExoPlayerFactory { * @param loadControl The {@link LoadControl} that will be used by the instance. * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance * will not be used for DRM protected playbacks. - * @param preferExtensionDecoders True to prefer {@link Renderer} instances defined in - * available extensions over those defined in the core library. Note that extensions must be - * included in the application build for setting this flag to have any effect. + * @param extensionRendererMode The extension renderer mode, which determines if and how available + * extension renderers are used. Note that extensions must be included in the application + * build for them to be considered available. * @param allowedVideoJoiningTimeMs The maximum duration for which a video renderer can attempt to * seamlessly join an ongoing playback. */ - public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, + public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector, LoadControl loadControl, DrmSessionManager drmSessionManager, - boolean preferExtensionDecoders, long allowedVideoJoiningTimeMs) { + @SimpleExoPlayer.ExtensionRendererMode int extensionRendererMode, + long allowedVideoJoiningTimeMs) { return new SimpleExoPlayer(context, trackSelector, loadControl, drmSessionManager, - preferExtensionDecoders, allowedVideoJoiningTimeMs); + extensionRendererMode, allowedVideoJoiningTimeMs); } /** @@ -111,7 +113,7 @@ public final class ExoPlayerFactory { * @param renderers The {@link Renderer}s that will be used by the instance. * @param trackSelector The {@link TrackSelector} that will be used by the instance. */ - public static ExoPlayer newInstance(Renderer[] renderers, TrackSelector trackSelector) { + public static ExoPlayer newInstance(Renderer[] renderers, TrackSelector trackSelector) { return newInstance(renderers, trackSelector, new DefaultLoadControl()); } @@ -123,7 +125,7 @@ public final class ExoPlayerFactory { * @param trackSelector The {@link TrackSelector} that will be used by the instance. * @param loadControl The {@link LoadControl} that will be used by the instance. */ - public static ExoPlayer newInstance(Renderer[] renderers, TrackSelector trackSelector, + public static ExoPlayer newInstance(Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { return new ExoPlayerImpl(renderers, trackSelector, loadControl); } diff --git a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 3eb2ceb38b..a7cbeb524c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -20,11 +20,16 @@ import android.os.Handler; import android.os.Looper; import android.os.Message; import android.util.Log; -import android.util.Pair; import com.google.android.exoplayer2.ExoPlayerImplInternal.PlaybackInfo; +import com.google.android.exoplayer2.ExoPlayerImplInternal.SourceInfo; +import com.google.android.exoplayer2.ExoPlayerImplInternal.TrackInfo; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; import java.util.concurrent.CopyOnWriteArraySet; /** @@ -34,19 +39,24 @@ import java.util.concurrent.CopyOnWriteArraySet; private static final String TAG = "ExoPlayerImpl"; + private final Renderer[] renderers; + private final TrackSelector trackSelector; + private final TrackSelectionArray emptyTrackSelections; private final Handler eventHandler; - private final ExoPlayerImplInternal internalPlayer; + private final ExoPlayerImplInternal internalPlayer; private final CopyOnWriteArraySet listeners; private final Timeline.Window window; private final Timeline.Period period; - private boolean pendingInitialSeek; + private boolean tracksSelected; private boolean playWhenReady; private int playbackState; private int pendingSeekAcks; private boolean isLoading; private Timeline timeline; private Object manifest; + private TrackGroupArray trackGroups; + private TrackSelectionArray trackSelections; // Playback information when there is no pending seek/set source operation. private PlaybackInfo playbackInfo; @@ -63,16 +73,20 @@ import java.util.concurrent.CopyOnWriteArraySet; * @param loadControl The {@link LoadControl} that will be used by the instance. */ @SuppressLint("HandlerLeak") - public ExoPlayerImpl(Renderer[] renderers, TrackSelector trackSelector, - LoadControl loadControl) { - Log.i(TAG, "Init " + ExoPlayerLibraryInfo.VERSION); - Assertions.checkNotNull(renderers); + public ExoPlayerImpl(Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { + Log.i(TAG, "Init " + ExoPlayerLibraryInfo.VERSION + " [" + Util.DEVICE_DEBUG_INFO + "]"); Assertions.checkState(renderers.length > 0); + this.renderers = Assertions.checkNotNull(renderers); + this.trackSelector = Assertions.checkNotNull(trackSelector); this.playWhenReady = false; this.playbackState = STATE_IDLE; this.listeners = new CopyOnWriteArraySet<>(); + emptyTrackSelections = new TrackSelectionArray(new TrackSelection[renderers.length]); + timeline = Timeline.EMPTY; window = new Timeline.Window(); period = new Timeline.Period(); + trackGroups = TrackGroupArray.EMPTY; + trackSelections = emptyTrackSelections; eventHandler = new Handler() { @Override public void handleMessage(Message msg) { @@ -80,8 +94,8 @@ import java.util.concurrent.CopyOnWriteArraySet; } }; playbackInfo = new ExoPlayerImplInternal.PlaybackInfo(0, 0); - internalPlayer = new ExoPlayerImplInternal<>(renderers, trackSelector, loadControl, - playWhenReady, eventHandler, playbackInfo); + internalPlayer = new ExoPlayerImplInternal(renderers, trackSelector, loadControl, playWhenReady, + eventHandler, playbackInfo, this); } @Override @@ -105,12 +119,23 @@ import java.util.concurrent.CopyOnWriteArraySet; } @Override - public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetTimeline) { - if (resetTimeline && (timeline != null || manifest != null)) { - timeline = null; - manifest = null; - for (EventListener listener : listeners) { - listener.onTimelineChanged(null, null); + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + if (resetState) { + if (!timeline.isEmpty() || manifest != null) { + timeline = Timeline.EMPTY; + manifest = null; + for (EventListener listener : listeners) { + listener.onTimelineChanged(timeline, manifest); + } + } + if (tracksSelected) { + tracksSelected = false; + trackGroups = TrackGroupArray.EMPTY; + trackSelections = emptyTrackSelections; + trackSelector.onSelectionActivated(null); + for (EventListener listener : listeners) { + listener.onTracksChanged(trackGroups, trackSelections); + } } } internalPlayer.prepare(mediaSource, resetPosition); @@ -144,17 +169,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public void seekToDefaultPosition(int windowIndex) { - if (timeline == null) { - maskingWindowIndex = windowIndex; - maskingWindowPositionMs = C.TIME_UNSET; - pendingInitialSeek = true; - } else { - Assertions.checkIndex(windowIndex, 0, timeline.getWindowCount()); - pendingSeekAcks++; - maskingWindowIndex = windowIndex; - maskingWindowPositionMs = 0; - internalPlayer.seekTo(timeline.getWindow(windowIndex, window).firstPeriodIndex, C.TIME_UNSET); - } + seekTo(windowIndex, C.TIME_UNSET); } @Override @@ -164,27 +179,17 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public void seekTo(int windowIndex, long positionMs) { + if (windowIndex < 0 || (!timeline.isEmpty() && windowIndex >= timeline.getWindowCount())) { + throw new IllegalSeekPositionException(timeline, windowIndex, positionMs); + } + pendingSeekAcks++; + maskingWindowIndex = windowIndex; if (positionMs == C.TIME_UNSET) { - seekToDefaultPosition(windowIndex); - } else if (timeline == null) { - maskingWindowIndex = windowIndex; - maskingWindowPositionMs = positionMs; - pendingInitialSeek = true; + maskingWindowPositionMs = 0; + internalPlayer.seekTo(timeline, windowIndex, C.TIME_UNSET); } else { - Assertions.checkIndex(windowIndex, 0, timeline.getWindowCount()); - pendingSeekAcks++; - maskingWindowIndex = windowIndex; maskingWindowPositionMs = positionMs; - timeline.getWindow(windowIndex, window); - int periodIndex = window.firstPeriodIndex; - long periodPositionMs = window.getPositionInFirstPeriodMs() + positionMs; - long periodDurationMs = timeline.getPeriod(periodIndex, period).getDurationMs(); - while (periodDurationMs != C.TIME_UNSET && periodPositionMs >= periodDurationMs - && periodIndex < window.lastPeriodIndex) { - periodPositionMs -= periodDurationMs; - periodDurationMs = timeline.getPeriod(++periodIndex, period).getDurationMs(); - } - internalPlayer.seekTo(periodIndex, C.msToUs(periodPositionMs)); + internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs)); for (EventListener listener : listeners) { listener.onPositionDiscontinuity(); } @@ -219,7 +224,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public int getCurrentWindowIndex() { - if (timeline == null || pendingSeekAcks > 0) { + if (timeline.isEmpty() || pendingSeekAcks > 0) { return maskingWindowIndex; } else { return timeline.getPeriod(playbackInfo.periodIndex, period).windowIndex; @@ -228,7 +233,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public long getDuration() { - if (timeline == null) { + if (timeline.isEmpty()) { return C.TIME_UNSET; } return timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs(); @@ -236,7 +241,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public long getCurrentPosition() { - if (timeline == null || pendingSeekAcks > 0) { + if (timeline.isEmpty() || pendingSeekAcks > 0) { return maskingWindowPositionMs; } else { timeline.getPeriod(playbackInfo.periodIndex, period); @@ -247,7 +252,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public long getBufferedPosition() { // TODO - Implement this properly. - if (timeline == null || pendingSeekAcks > 0) { + if (timeline.isEmpty() || pendingSeekAcks > 0) { return maskingWindowPositionMs; } else { timeline.getPeriod(playbackInfo.periodIndex, period); @@ -257,7 +262,7 @@ import java.util.concurrent.CopyOnWriteArraySet; @Override public int getBufferedPercentage() { - if (timeline == null) { + if (timeline.isEmpty()) { return 0; } long bufferedPosition = getBufferedPosition(); @@ -266,6 +271,26 @@ import java.util.concurrent.CopyOnWriteArraySet; : (int) (duration == 0 ? 100 : (bufferedPosition * 100) / duration); } + @Override + public int getRendererCount() { + return renderers.length; + } + + @Override + public int getRendererType(int index) { + return renderers[index].getTrackType(); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return trackGroups; + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return trackSelections; + } + @Override public Timeline getCurrentTimeline() { return timeline; @@ -293,6 +318,17 @@ import java.util.concurrent.CopyOnWriteArraySet; } break; } + case ExoPlayerImplInternal.MSG_TRACKS_CHANGED: { + TrackInfo trackInfo = (TrackInfo) msg.obj; + tracksSelected = true; + trackGroups = trackInfo.groups; + trackSelections = trackInfo.selections; + trackSelector.onSelectionActivated(trackInfo.info); + for (EventListener listener : listeners) { + listener.onTracksChanged(trackGroups, trackSelections); + } + break; + } case ExoPlayerImplInternal.MSG_SEEK_ACK: { if (--pendingSeekAcks == 0) { playbackInfo = (ExoPlayerImplInternal.PlaybackInfo) msg.obj; @@ -312,14 +348,11 @@ import java.util.concurrent.CopyOnWriteArraySet; break; } case ExoPlayerImplInternal.MSG_SOURCE_INFO_REFRESHED: { - @SuppressWarnings("unchecked") - Pair timelineAndManifest = (Pair) msg.obj; - timeline = timelineAndManifest.first; - manifest = timelineAndManifest.second; - if (pendingInitialSeek) { - pendingInitialSeek = false; - seekTo(maskingWindowIndex, maskingWindowPositionMs); - } + SourceInfo sourceInfo = (SourceInfo) msg.obj; + timeline = sourceInfo.timeline; + manifest = sourceInfo.manifest; + playbackInfo = sourceInfo.playbackInfo; + pendingSeekAcks -= sourceInfo.seekAcks; for (EventListener listener : listeners) { listener.onTimelineChanged(timeline, manifest); } diff --git a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 56b862073a..66be6b7478 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -26,8 +26,9 @@ import com.google.android.exoplayer2.ExoPlayer.ExoPlayerMessage; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelections; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MediaClock; @@ -40,7 +41,7 @@ import java.io.IOException; /** * Implements the internal behavior of {@link ExoPlayerImpl}. */ -/* package */ final class ExoPlayerImplInternal implements Handler.Callback, +/* package */ final class ExoPlayerImplInternal implements Handler.Callback, MediaPeriod.Callback, TrackSelector.InvalidationListener, MediaSource.Listener { /** @@ -62,6 +63,43 @@ import java.io.IOException; bufferedPositionUs = startPositionUs; } + public PlaybackInfo copyWithPeriodIndex(int periodIndex) { + PlaybackInfo playbackInfo = new PlaybackInfo(periodIndex, startPositionUs); + playbackInfo.positionUs = positionUs; + playbackInfo.bufferedPositionUs = bufferedPositionUs; + return playbackInfo; + } + + } + + public static final class TrackInfo { + + public final TrackGroupArray groups; + public final TrackSelectionArray selections; + public final Object info; + + public TrackInfo(TrackGroupArray groups, TrackSelectionArray selections, Object info) { + this.groups = groups; + this.selections = selections; + this.info = info; + } + + } + + public static final class SourceInfo { + + public final Timeline timeline; + public final Object manifest; + public final PlaybackInfo playbackInfo; + public final int seekAcks; + + public SourceInfo(Timeline timeline, Object manifest, PlaybackInfo playbackInfo, int seekAcks) { + this.timeline = timeline; + this.manifest = manifest; + this.playbackInfo = playbackInfo; + this.seekAcks = seekAcks; + } + } private static final String TAG = "ExoPlayerImplInternal"; @@ -69,10 +107,11 @@ import java.io.IOException; // External messages public static final int MSG_STATE_CHANGED = 1; public static final int MSG_LOADING_CHANGED = 2; - public static final int MSG_SEEK_ACK = 3; - public static final int MSG_POSITION_DISCONTINUITY = 4; - public static final int MSG_SOURCE_INFO_REFRESHED = 5; - public static final int MSG_ERROR = 6; + public static final int MSG_TRACKS_CHANGED = 3; + public static final int MSG_SEEK_ACK = 4; + public static final int MSG_POSITION_DISCONTINUITY = 5; + public static final int MSG_SOURCE_INFO_REFRESHED = 6; + public static final int MSG_ERROR = 7; // Internal messages private static final int MSG_PREPARE = 0; @@ -98,14 +137,23 @@ import java.io.IOException; */ private static final int MAXIMUM_BUFFER_AHEAD_PERIODS = 100; + /** + * Offset added to all sample timestamps read by renderers to make them non-negative. This is + * provided for convenience of sources that may return negative timestamps due to prerolling + * samples from a keyframe before their first sample with timestamp zero, so it must be set to a + * value greater than or equal to the maximum key-frame interval in seekable periods. + */ + private static final int RENDERER_TIMESTAMP_OFFSET_US = 60000000; + private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; - private final TrackSelector trackSelector; + private final TrackSelector trackSelector; private final LoadControl loadControl; private final StandaloneMediaClock standaloneMediaClock; private final Handler handler; private final HandlerThread internalPlaybackThread; private final Handler eventHandler; + private final ExoPlayer player; private final Timeline.Window window; private final Timeline.Period period; @@ -123,20 +171,19 @@ import java.io.IOException; private int customMessagesProcessed; private long elapsedRealtimeUs; + private int pendingInitialSeekCount; + private SeekPosition pendingSeekPosition; private long rendererPositionUs; - private boolean isTimelineReady; - private boolean isTimelineEnded; - private int bufferAheadPeriodCount; - private MediaPeriodHolder playingPeriodHolder; - private MediaPeriodHolder readingPeriodHolder; - private MediaPeriodHolder loadingPeriodHolder; + private MediaPeriodHolder loadingPeriodHolder; + private MediaPeriodHolder readingPeriodHolder; + private MediaPeriodHolder playingPeriodHolder; private Timeline timeline; - public ExoPlayerImplInternal(Renderer[] renderers, TrackSelector trackSelector, + public ExoPlayerImplInternal(Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl, boolean playWhenReady, Handler eventHandler, - PlaybackInfo playbackInfo) { + PlaybackInfo playbackInfo, ExoPlayer player) { this.renderers = renderers; this.trackSelector = trackSelector; this.loadControl = loadControl; @@ -144,6 +191,7 @@ import java.io.IOException; this.eventHandler = eventHandler; this.state = ExoPlayer.STATE_IDLE; this.playbackInfo = playbackInfo; + this.player = player; rendererCapabilities = new RendererCapabilities[renderers.length]; for (int i = 0; i < renderers.length; i++) { @@ -173,8 +221,9 @@ import java.io.IOException; handler.obtainMessage(MSG_SET_PLAY_WHEN_READY, playWhenReady ? 1 : 0, 0).sendToTarget(); } - public void seekTo(int periodIndex, long positionUs) { - handler.obtainMessage(MSG_SEEK_TO, periodIndex, 0, positionUs).sendToTarget(); + public void seekTo(Timeline timeline, int windowIndex, long positionUs) { + handler.obtainMessage(MSG_SEEK_TO, new SeekPosition(timeline, windowIndex, positionUs)) + .sendToTarget(); } public void stop() { @@ -249,6 +298,7 @@ import java.io.IOException; // Handler.Callback implementation. + @SuppressWarnings("unchecked") @Override public boolean handleMessage(Message msg) { try { @@ -266,7 +316,7 @@ import java.io.IOException; return true; } case MSG_SEEK_TO: { - seekToInternal(msg.arg1, (Long) msg.obj); + seekToInternal((SeekPosition) msg.obj); return true; } case MSG_STOP: { @@ -335,15 +385,14 @@ import java.io.IOException; } } - private void prepareInternal(MediaSource mediaSource, boolean resetPosition) - throws ExoPlaybackException { - resetInternal(); + private void prepareInternal(MediaSource mediaSource, boolean resetPosition) { + resetInternal(true); loadControl.onPrepared(); if (resetPosition) { playbackInfo = new PlaybackInfo(0, C.TIME_UNSET); } this.mediaSource = mediaSource; - mediaSource.prepareSource(this); + mediaSource.prepareSource(player, true, this); setState(ExoPlayer.STATE_BUFFERING); handler.sendEmptyMessage(MSG_DO_SOME_WORK); } @@ -395,7 +444,7 @@ import java.io.IOException; } else { rendererPositionUs = standaloneMediaClock.getPositionUs(); } - periodPositionUs = rendererPositionUs - playingPeriodHolder.rendererPositionOffsetUs; + periodPositionUs = playingPeriodHolder.toPeriodTime(rendererPositionUs); } playbackInfo.positionUs = periodPositionUs; elapsedRealtimeUs = SystemClock.elapsedRealtime() * 1000; @@ -410,7 +459,6 @@ import java.io.IOException; private void doSomeWork() throws ExoPlaybackException, IOException { long operationStartTimeMs = SystemClock.elapsedRealtime(); - updatePeriods(); if (playingPeriodHolder == null) { // We're still waiting for the first period to be prepared. @@ -448,22 +496,27 @@ import java.io.IOException; if (allRenderersEnded && (playingPeriodDurationUs == C.TIME_UNSET || playingPeriodDurationUs <= playbackInfo.positionUs) - && isTimelineEnded) { + && playingPeriodHolder.isLast) { setState(ExoPlayer.STATE_ENDED); stopRenderers(); } else if (state == ExoPlayer.STATE_BUFFERING) { - if ((enabledRenderers.length > 0 - ? (allRenderersReadyOrEnded && haveSufficientBuffer(rebuffering)) : isTimelineReady)) { + boolean isNewlyReady = enabledRenderers.length > 0 + ? (allRenderersReadyOrEnded && haveSufficientBuffer(rebuffering)) + : isTimelineReady(playingPeriodDurationUs); + if (isNewlyReady) { setState(ExoPlayer.STATE_READY); if (playWhenReady) { startRenderers(); } } - } else if (state == ExoPlayer.STATE_READY - && (enabledRenderers.length > 0 ? !allRenderersReadyOrEnded : !isTimelineReady)) { - rebuffering = playWhenReady; - setState(ExoPlayer.STATE_BUFFERING); - stopRenderers(); + } else if (state == ExoPlayer.STATE_READY) { + boolean isStillReady = enabledRenderers.length > 0 ? allRenderersReadyOrEnded + : isTimelineReady(playingPeriodDurationUs); + if (!isStillReady) { + rebuffering = playWhenReady; + setState(ExoPlayer.STATE_BUFFERING); + stopRenderers(); + } } if (state == ExoPlayer.STATE_BUFFERING) { @@ -494,19 +547,34 @@ import java.io.IOException; } } - private void seekToInternal(int periodIndex, long periodPositionUs) throws ExoPlaybackException { - try { - if (periodPositionUs == C.TIME_UNSET && timeline != null - && periodIndex < timeline.getPeriodCount()) { - // We know about the window, so seek to its default initial position now. - Pair defaultPosition = getDefaultPosition(periodIndex); - periodIndex = defaultPosition.first; - periodPositionUs = defaultPosition.second; - } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { + if (timeline == null) { + pendingInitialSeekCount++; + pendingSeekPosition = seekPosition; + return; + } + Pair periodPosition = resolveSeekPosition(seekPosition); + if (periodPosition == null) { + // The seek position was valid for the timeline that it was performed into, but the + // timeline has changed and a suitable seek position could not be resolved in the new one. + playbackInfo = new PlaybackInfo(0, 0); + eventHandler.obtainMessage(MSG_SEEK_ACK, playbackInfo).sendToTarget(); + // Set the internal position to (0,TIME_UNSET) so that a subsequent seek to (0,0) isn't + // ignored. + playbackInfo = new PlaybackInfo(0, C.TIME_UNSET); + setState(ExoPlayer.STATE_ENDED); + // Reset, but retain the source so that it can still be used should a seek occur. + resetInternal(false); + return; + } + + int periodIndex = periodPosition.first; + long periodPositionUs = periodPosition.second; + + try { if (periodIndex == playbackInfo.periodIndex - && ((periodPositionUs == C.TIME_UNSET && playbackInfo.positionUs == C.TIME_UNSET) - || ((periodPositionUs / 1000) == (playbackInfo.positionUs / 1000)))) { + && ((periodPositionUs / 1000) == (playbackInfo.positionUs / 1000))) { // Seek position equals the current position. Do nothing. return; } @@ -519,26 +587,11 @@ import java.io.IOException; private long seekToPeriodPosition(int periodIndex, long periodPositionUs) throws ExoPlaybackException { - if (mediaSource == null) { - if (periodPositionUs != C.TIME_UNSET) { - resetRendererPosition(periodPositionUs); - } - return periodPositionUs; - } - stopRenderers(); rebuffering = false; setState(ExoPlayer.STATE_BUFFERING); - if (periodPositionUs == C.TIME_UNSET || (readingPeriodHolder != playingPeriodHolder - && (periodIndex == playingPeriodHolder.index - || periodIndex == readingPeriodHolder.index))) { - // Clear the timeline because either the seek position is not known, or a renderer is reading - // ahead to the next period and the seek is to either the playing or reading period. - periodIndex = C.INDEX_UNSET; - } - - MediaPeriodHolder newPlayingPeriodHolder = null; + MediaPeriodHolder newPlayingPeriodHolder = null; if (playingPeriodHolder == null) { // We're still waiting for the first period to be prepared. if (loadingPeriodHolder != null) { @@ -546,7 +599,7 @@ import java.io.IOException; } } else { // Clear the timeline, but keep the requested period if it is already prepared. - MediaPeriodHolder periodHolder = playingPeriodHolder; + MediaPeriodHolder periodHolder = playingPeriodHolder; while (periodHolder != null) { if (periodHolder.index == periodIndex && periodHolder.prepared) { newPlayingPeriodHolder = periodHolder; @@ -557,8 +610,10 @@ import java.io.IOException; } } - // Disable all the renderers if the period is changing. - if (newPlayingPeriodHolder != playingPeriodHolder) { + // Disable all the renderers if the period being played is changing, or if the renderers are + // reading from a period other than the one being played. + if (playingPeriodHolder != newPlayingPeriodHolder + || playingPeriodHolder != readingPeriodHolder) { for (Renderer renderer : enabledRenderers) { renderer.disable(); } @@ -567,36 +622,32 @@ import java.io.IOException; rendererMediaClockSource = null; } - // Update loaded periods. - bufferAheadPeriodCount = 0; + // Update the holders. if (newPlayingPeriodHolder != null) { newPlayingPeriodHolder.next = null; + loadingPeriodHolder = newPlayingPeriodHolder; + readingPeriodHolder = newPlayingPeriodHolder; setPlayingPeriodHolder(newPlayingPeriodHolder); - updateTimelineState(); - readingPeriodHolder = playingPeriodHolder; - loadingPeriodHolder = playingPeriodHolder; if (playingPeriodHolder.hasEnabledTracks) { periodPositionUs = playingPeriodHolder.mediaPeriod.seekToUs(periodPositionUs); } resetRendererPosition(periodPositionUs); maybeContinueLoading(); } else { - playingPeriodHolder = null; - readingPeriodHolder = null; loadingPeriodHolder = null; - if (periodPositionUs != C.TIME_UNSET) { - resetRendererPosition(periodPositionUs); - } + readingPeriodHolder = null; + playingPeriodHolder = null; + resetRendererPosition(periodPositionUs); } - updatePlaybackPositions(); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); return periodPositionUs; } private void resetRendererPosition(long periodPositionUs) throws ExoPlaybackException { - long periodOffsetUs = playingPeriodHolder == null ? 0 - : playingPeriodHolder.rendererPositionOffsetUs; - rendererPositionUs = periodOffsetUs + periodPositionUs; + rendererPositionUs = playingPeriodHolder == null + ? periodPositionUs + RENDERER_TIMESTAMP_OFFSET_US + : playingPeriodHolder.toRendererTime(periodPositionUs); standaloneMediaClock.setPositionUs(rendererPositionUs); for (Renderer renderer : enabledRenderers) { renderer.resetPosition(rendererPositionUs); @@ -604,13 +655,13 @@ import java.io.IOException; } private void stopInternal() { - resetInternal(); + resetInternal(true); loadControl.onStopped(); setState(ExoPlayer.STATE_IDLE); } private void releaseInternal() { - resetInternal(); + resetInternal(true); loadControl.onReleased(); setState(ExoPlayer.STATE_IDLE); synchronized (this) { @@ -619,7 +670,7 @@ import java.io.IOException; } } - private void resetInternal() { + private void resetInternal(boolean releaseMediaSource) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; standaloneMediaClock.stop(); @@ -637,18 +688,17 @@ import java.io.IOException; enabledRenderers = new Renderer[0]; releasePeriodHoldersFrom(playingPeriodHolder != null ? playingPeriodHolder : loadingPeriodHolder); - if (mediaSource != null) { - mediaSource.releaseSource(); - mediaSource = null; - } - isTimelineReady = false; - isTimelineEnded = false; - playingPeriodHolder = null; - readingPeriodHolder = null; loadingPeriodHolder = null; - timeline = null; - bufferAheadPeriodCount = 0; + readingPeriodHolder = null; + playingPeriodHolder = null; setIsLoading(false); + if (releaseMediaSource) { + if (mediaSource != null) { + mediaSource.releaseSource(); + mediaSource = null; + } + timeline = null; + } } private void sendMessagesInternal(ExoPlayerMessage[] messages) throws ExoPlaybackException { @@ -680,7 +730,7 @@ import java.io.IOException; return; } // Reselect tracks on each period in turn, until the selection changes. - MediaPeriodHolder periodHolder = playingPeriodHolder; + MediaPeriodHolder periodHolder = playingPeriodHolder; boolean selectionsChangedForReadPeriod = true; while (true) { if (periodHolder == null || !periodHolder.prepared) { @@ -703,13 +753,12 @@ import java.io.IOException; boolean recreateStreams = readingPeriodHolder != playingPeriodHolder; releasePeriodHoldersFrom(playingPeriodHolder.next); playingPeriodHolder.next = null; - readingPeriodHolder = playingPeriodHolder; loadingPeriodHolder = playingPeriodHolder; - bufferAheadPeriodCount = 0; + readingPeriodHolder = playingPeriodHolder; boolean[] streamResetFlags = new boolean[renderers.length]; long periodPositionUs = playingPeriodHolder.updatePeriodTrackSelection( - playbackInfo.positionUs, loadControl, recreateStreams, streamResetFlags); + playbackInfo.positionUs, recreateStreams, streamResetFlags); if (periodPositionUs != playbackInfo.positionUs) { playbackInfo.positionUs = periodPositionUs; resetRendererPosition(periodPositionUs); @@ -741,11 +790,11 @@ import java.io.IOException; renderer.disable(); } else if (streamResetFlags[i]) { // The renderer will continue to consume from its current stream, but needs to be reset. - renderer.resetPosition(playbackInfo.positionUs); + renderer.resetPosition(rendererPositionUs); } } } - trackSelector.onSelectionActivated(playingPeriodHolder.trackSelections); + eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.getTrackInfo()).sendToTarget(); enableRenderers(rendererWasEnabledFlags, enabledRendererCount); } else { // Release and re-prepare/buffer periods after the one whose selection changed. @@ -754,26 +803,32 @@ import java.io.IOException; while (periodHolder != null) { periodHolder.release(); periodHolder = periodHolder.next; - bufferAheadPeriodCount--; } loadingPeriodHolder.next = null; - long loadingPeriodPositionUs = Math.max(0, - rendererPositionUs - loadingPeriodHolder.rendererPositionOffsetUs); - loadingPeriodHolder.updatePeriodTrackSelection(loadingPeriodPositionUs, loadControl, false); + if (loadingPeriodHolder.prepared) { + long loadingPeriodPositionUs = Math.max(loadingPeriodHolder.startPositionUs, + loadingPeriodHolder.toPeriodTime(rendererPositionUs)); + loadingPeriodHolder.updatePeriodTrackSelection(loadingPeriodPositionUs, false); + } } maybeContinueLoading(); updatePlaybackPositions(); handler.sendEmptyMessage(MSG_DO_SOME_WORK); } + private boolean isTimelineReady(long playingPeriodDurationUs) { + return playingPeriodDurationUs == C.TIME_UNSET + || playbackInfo.positionUs < playingPeriodDurationUs + || (playingPeriodHolder.next != null && playingPeriodHolder.next.prepared); + } + private boolean haveSufficientBuffer(boolean rebuffering) { if (loadingPeriodHolder == null) { return false; } - long loadingPeriodPositionUs = rendererPositionUs - - loadingPeriodHolder.rendererPositionOffsetUs; - long loadingPeriodBufferedPositionUs = - !loadingPeriodHolder.prepared ? 0 : loadingPeriodHolder.mediaPeriod.getBufferedPositionUs(); + long loadingPeriodBufferedPositionUs = !loadingPeriodHolder.prepared + ? loadingPeriodHolder.startPositionUs + : loadingPeriodHolder.mediaPeriod.getBufferedPositionUs(); if (loadingPeriodBufferedPositionUs == C.TIME_END_OF_SOURCE) { if (loadingPeriodHolder.isLast) { return true; @@ -782,7 +837,8 @@ import java.io.IOException; .getDurationUs(); } return loadControl.shouldStartPlayback( - loadingPeriodBufferedPositionUs - loadingPeriodPositionUs, rebuffering); + loadingPeriodBufferedPositionUs - loadingPeriodHolder.toPeriodTime(rendererPositionUs), + rebuffering); } private void maybeThrowPeriodPrepareError() throws IOException { @@ -798,134 +854,250 @@ import java.io.IOException; } private void handleSourceInfoRefreshed(Pair timelineAndManifest) - throws ExoPlaybackException, IOException { - eventHandler.obtainMessage(MSG_SOURCE_INFO_REFRESHED, timelineAndManifest).sendToTarget(); - Timeline oldTimeline = this.timeline; - this.timeline = timelineAndManifest.first; + throws ExoPlaybackException { + Timeline oldTimeline = timeline; + timeline = timelineAndManifest.first; + Object manifest = timelineAndManifest.second; - // Update the loaded periods to take into account the new timeline. - if (playingPeriodHolder != null) { - int index = timeline.getIndexOfPeriod(playingPeriodHolder.uid); - if (index == C.INDEX_UNSET) { - attemptRestart(timeline, oldTimeline, playingPeriodHolder.index); - return; - } - - // The playing period is also in the new timeline. Update the index for each loaded period - // until a period is found that does not match the old timeline. - timeline.getPeriod(index, period, true); - playingPeriodHolder.setIndex(timeline, timeline.getWindow(period.windowIndex, window), - index); - - MediaPeriodHolder previousPeriodHolder = playingPeriodHolder; - boolean seenReadingPeriod = false; - bufferAheadPeriodCount = 0; - while (previousPeriodHolder.next != null) { - MediaPeriodHolder periodHolder = previousPeriodHolder.next; - index++; - timeline.getPeriod(index, period, true); - if (!periodHolder.uid.equals(period.uid)) { - if (!seenReadingPeriod) { - // Renderers may have read a period that has been removed, so release all loaded periods - // and seek to the current position of the playing period index. - index = playingPeriodHolder.index; - releasePeriodHoldersFrom(playingPeriodHolder); - playingPeriodHolder = null; - readingPeriodHolder = null; - loadingPeriodHolder = null; - long newPositionUs = seekToPeriodPosition(index, playbackInfo.positionUs); - if (newPositionUs != playbackInfo.positionUs) { - playbackInfo = new PlaybackInfo(index, newPositionUs); - eventHandler.obtainMessage(MSG_POSITION_DISCONTINUITY, playbackInfo).sendToTarget(); - } - return; - } - - // Update the loading period to be the latest period that is still valid. - loadingPeriodHolder = previousPeriodHolder; - loadingPeriodHolder.next = null; - - // Release the rest of the timeline. - releasePeriodHoldersFrom(periodHolder); - break; + int processedInitialSeekCount = 0; + if (oldTimeline == null) { + if (pendingInitialSeekCount > 0) { + Pair periodPosition = resolveSeekPosition(pendingSeekPosition); + processedInitialSeekCount = pendingInitialSeekCount; + pendingInitialSeekCount = 0; + pendingSeekPosition = null; + if (periodPosition == null) { + // The seek position was valid for the timeline that it was performed into, but the + // timeline has changed and a suitable seek position could not be resolved in the new one. + handleSourceInfoRefreshEndedPlayback(manifest, processedInitialSeekCount); + return; } - - bufferAheadPeriodCount++; - int windowIndex = timeline.getPeriod(index, period).windowIndex; - periodHolder.setIndex(timeline, timeline.getWindow(windowIndex, window), index); - if (periodHolder == readingPeriodHolder) { - seenReadingPeriod = true; + playbackInfo = new PlaybackInfo(periodPosition.first, periodPosition.second); + } else if (playbackInfo.startPositionUs == C.TIME_UNSET) { + if (timeline.isEmpty()) { + handleSourceInfoRefreshEndedPlayback(manifest, processedInitialSeekCount); + return; } - previousPeriodHolder = periodHolder; - } - } else if (loadingPeriodHolder != null) { - Object uid = loadingPeriodHolder.uid; - int index = timeline.getIndexOfPeriod(uid); - if (index == C.INDEX_UNSET) { - attemptRestart(timeline, oldTimeline, loadingPeriodHolder.index); - return; - } else { - int windowIndex = timeline.getPeriod(index, this.period).windowIndex; - loadingPeriodHolder.setIndex(timeline, timeline.getWindow(windowIndex, window), - index); + Pair defaultPosition = getPeriodPosition(0, C.TIME_UNSET); + playbackInfo = new PlaybackInfo(defaultPosition.first, defaultPosition.second); } } - // TODO[playlists]: Signal the identifier discontinuity, even if the index hasn't changed. - if (oldTimeline != null) { - int newPlayingIndex = playingPeriodHolder != null ? playingPeriodHolder.index - : loadingPeriodHolder != null ? loadingPeriodHolder.index : C.INDEX_UNSET; - if (newPlayingIndex != C.INDEX_UNSET - && newPlayingIndex != playbackInfo.periodIndex) { - playbackInfo = new PlaybackInfo(newPlayingIndex, playbackInfo.positionUs); - updatePlaybackPositions(); - eventHandler.obtainMessage(MSG_POSITION_DISCONTINUITY, playbackInfo).sendToTarget(); - } - } - } - - private void attemptRestart(Timeline newTimeline, Timeline oldTimeline, - int oldPeriodIndex) throws ExoPlaybackException { - int newPeriodIndex = C.INDEX_UNSET; - while (newPeriodIndex == C.INDEX_UNSET - && oldPeriodIndex < oldTimeline.getPeriodCount() - 1) { - newPeriodIndex = - newTimeline.getIndexOfPeriod(oldTimeline.getPeriod(++oldPeriodIndex, period, true).uid); - } - if (newPeriodIndex == C.INDEX_UNSET) { - // We failed to find a replacement period. Stop the player. - stopInternal(); + MediaPeriodHolder periodHolder = playingPeriodHolder != null ? playingPeriodHolder + : loadingPeriodHolder; + if (periodHolder == null) { + // We don't have any period holders, so we're done. + notifySourceInfoRefresh(manifest, processedInitialSeekCount); return; } - // Release all loaded periods. - releasePeriodHoldersFrom(playingPeriodHolder != null ? playingPeriodHolder - : loadingPeriodHolder); - bufferAheadPeriodCount = 0; - playingPeriodHolder = null; - readingPeriodHolder = null; - loadingPeriodHolder = null; + int periodIndex = timeline.getIndexOfPeriod(periodHolder.uid); + if (periodIndex == C.INDEX_UNSET) { + // We didn't find the current period in the new timeline. Attempt to resolve a subsequent + // period whose window we can restart from. + int newPeriodIndex = resolveSubsequentPeriod(periodHolder.index, oldTimeline, timeline); + if (newPeriodIndex == C.INDEX_UNSET) { + // We failed to resolve a suitable restart position. + handleSourceInfoRefreshEndedPlayback(manifest, processedInitialSeekCount); + return; + } + // We resolved a subsequent period. Seek to the default position in the corresponding window. + Pair defaultPosition = getPeriodPosition( + timeline.getPeriod(newPeriodIndex, period).windowIndex, C.TIME_UNSET); + newPeriodIndex = defaultPosition.first; + long newPositionUs = defaultPosition.second; + timeline.getPeriod(newPeriodIndex, period, true); + // Clear the index of each holder that doesn't contain the default position. If a holder + // contains the default position then update its index so it can be re-used when seeking. + Object newPeriodUid = period.uid; + periodHolder.index = C.INDEX_UNSET; + while (periodHolder.next != null) { + periodHolder = periodHolder.next; + periodHolder.index = periodHolder.uid.equals(newPeriodUid) ? newPeriodIndex : C.INDEX_UNSET; + } + // Actually do the seek. + newPositionUs = seekToPeriodPosition(newPeriodIndex, newPositionUs); + playbackInfo = new PlaybackInfo(newPeriodIndex, newPositionUs); + notifySourceInfoRefresh(manifest, processedInitialSeekCount); + return; + } - // Find the default initial position in the window and seek to it. - Pair defaultPosition = getDefaultPosition(newPeriodIndex); - newPeriodIndex = defaultPosition.first; - long newPlayingPositionUs = defaultPosition.second; + // The current period is in the new timeline. Update the holder and playbackInfo. + timeline.getPeriod(periodIndex, period); + boolean isLastPeriod = periodIndex == timeline.getPeriodCount() - 1 + && !timeline.getWindow(period.windowIndex, window).isDynamic; + periodHolder.setIndex(periodIndex, isLastPeriod); + boolean seenReadingPeriod = periodHolder == readingPeriodHolder; + if (periodIndex != playbackInfo.periodIndex) { + playbackInfo = playbackInfo.copyWithPeriodIndex(periodIndex); + } - playbackInfo = new PlaybackInfo(newPeriodIndex, newPlayingPositionUs); - eventHandler.obtainMessage(MSG_POSITION_DISCONTINUITY, playbackInfo).sendToTarget(); + // If there are subsequent holders, update the index for each of them. If we find a holder + // that's inconsistent with the new timeline then take appropriate action. + while (periodHolder.next != null) { + MediaPeriodHolder previousPeriodHolder = periodHolder; + periodHolder = periodHolder.next; + periodIndex++; + timeline.getPeriod(periodIndex, period, true); + isLastPeriod = periodIndex == timeline.getPeriodCount() - 1 + && !timeline.getWindow(period.windowIndex, window).isDynamic; + if (periodHolder.uid.equals(period.uid)) { + // The holder is consistent with the new timeline. Update its index and continue. + periodHolder.setIndex(periodIndex, isLastPeriod); + seenReadingPeriod |= (periodHolder == readingPeriodHolder); + } else { + // The holder is inconsistent with the new timeline. + if (!seenReadingPeriod) { + // Renderers may have read from a period that's been removed. Seek back to the current + // position of the playing period to make sure none of the removed period is played. + periodIndex = playingPeriodHolder.index; + long newPositionUs = seekToPeriodPosition(periodIndex, playbackInfo.positionUs); + playbackInfo = new PlaybackInfo(periodIndex, newPositionUs); + } else { + // Update the loading period to be the last period that's still valid, and release all + // subsequent periods. + loadingPeriodHolder = previousPeriodHolder; + loadingPeriodHolder.next = null; + // Release the rest of the timeline. + releasePeriodHoldersFrom(periodHolder); + } + break; + } + } + + notifySourceInfoRefresh(manifest, processedInitialSeekCount); } - private Pair getDefaultPosition(int periodIndex) { - timeline.getPeriod(periodIndex, period); - timeline.getWindow(period.windowIndex, window); - periodIndex = window.firstPeriodIndex; - long periodPositionUs = window.getPositionInFirstPeriodUs() - + window.getDefaultPositionUs(); - timeline.getPeriod(periodIndex, period); - while (periodIndex < window.lastPeriodIndex - && periodPositionUs > period.getDurationMs()) { - periodPositionUs -= period.getDurationUs(); - timeline.getPeriod(periodIndex++, period); + private void handleSourceInfoRefreshEndedPlayback(Object manifest, + int processedInitialSeekCount) { + // Set the playback position to (0,0) for notifying the eventHandler. + playbackInfo = new PlaybackInfo(0, 0); + notifySourceInfoRefresh(manifest, processedInitialSeekCount); + // Set the internal position to (0,TIME_UNSET) so that a subsequent seek to (0,0) isn't ignored. + playbackInfo = new PlaybackInfo(0, C.TIME_UNSET); + setState(ExoPlayer.STATE_ENDED); + // Reset, but retain the source so that it can still be used should a seek occur. + resetInternal(false); + } + + private void notifySourceInfoRefresh(Object manifest, int processedInitialSeekCount) { + eventHandler.obtainMessage(MSG_SOURCE_INFO_REFRESHED, + new SourceInfo(timeline, manifest, playbackInfo, processedInitialSeekCount)).sendToTarget(); + } + + /** + * Given a period index into an old timeline, finds the first subsequent period that also exists + * in a new timeline. The index of this period in the new timeline is returned. + * + * @param oldPeriodIndex The index of the period in the old timeline. + * @param oldTimeline The old timeline. + * @param newTimeline The new timeline. + * @return The index in the new timeline of the first subsequent period, or {@link C#INDEX_UNSET} + * if no such period was found. + */ + private int resolveSubsequentPeriod(int oldPeriodIndex, Timeline oldTimeline, + Timeline newTimeline) { + int newPeriodIndex = C.INDEX_UNSET; + while (newPeriodIndex == C.INDEX_UNSET && oldPeriodIndex < oldTimeline.getPeriodCount() - 1) { + newPeriodIndex = newTimeline.getIndexOfPeriod( + oldTimeline.getPeriod(++oldPeriodIndex, period, true).uid); + } + return newPeriodIndex; + } + + /** + * Converts a {@link SeekPosition} into the corresponding (periodIndex, periodPositionUs) for the + * internal timeline. + * + * @param seekPosition The position to resolve. + * @return The resolved position, or null if resolution was not successful. + * @throws IllegalSeekPositionException If the window index of the seek position is outside the + * bounds of the timeline. + */ + private Pair resolveSeekPosition(SeekPosition seekPosition) { + Timeline seekTimeline = seekPosition.timeline; + if (seekTimeline.isEmpty()) { + // The application performed a blind seek without a non-empty timeline (most likely based on + // knowledge of what the future timeline will be). Use the internal timeline. + seekTimeline = timeline; + } + // Map the SeekPosition to a position in the corresponding timeline. + Pair periodPosition; + try { + periodPosition = getPeriodPosition(seekTimeline, seekPosition.windowIndex, + seekPosition.windowPositionUs); + } catch (IndexOutOfBoundsException e) { + // The window index of the seek position was outside the bounds of the timeline. + throw new IllegalSeekPositionException(timeline, seekPosition.windowIndex, + seekPosition.windowPositionUs); + } + if (timeline == seekTimeline) { + // Our internal timeline is the seek timeline, so the mapped position is correct. + return periodPosition; + } + // Attempt to find the mapped period in the internal timeline. + int periodIndex = timeline.getIndexOfPeriod( + seekTimeline.getPeriod(periodPosition.first, period, true).uid); + if (periodIndex != C.INDEX_UNSET) { + // We successfully located the period in the internal timeline. + return Pair.create(periodIndex, periodPosition.second); + } + // Try and find a subsequent period from the seek timeline in the internal timeline. + periodIndex = resolveSubsequentPeriod(periodPosition.first, seekTimeline, timeline); + if (periodIndex != C.INDEX_UNSET) { + // We found one. Map the SeekPosition onto the corresponding default position. + return getPeriodPosition(timeline.getPeriod(periodIndex, period).windowIndex, C.TIME_UNSET); + } + // We didn't find one. Give up. + return null; + } + + /** + * Calls {@link #getPeriodPosition(Timeline, int, long)} using the current timeline. + */ + private Pair getPeriodPosition(int windowIndex, long windowPositionUs) { + return getPeriodPosition(timeline, windowIndex, windowPositionUs); + } + + /** + * Calls {@link #getPeriodPosition(Timeline, int, long, long)} with a zero default position + * projection. + */ + private Pair getPeriodPosition(Timeline timeline, int windowIndex, + long windowPositionUs) { + return getPeriodPosition(timeline, windowIndex, windowPositionUs, 0); + } + + /** + * Converts (windowIndex, windowPositionUs) to the corresponding (periodIndex, periodPositionUs). + * + * @param timeline The timeline containing the window. + * @param windowIndex The window index. + * @param windowPositionUs The window time, or {@link C#TIME_UNSET} to use the window's default + * start position. + * @param defaultPositionProjectionUs If {@code windowPositionUs} is {@link C#TIME_UNSET}, the + * duration into the future by which the window's position should be projected. + * @return The corresponding (periodIndex, periodPositionUs), or null if {@code #windowPositionUs} + * is {@link C#TIME_UNSET}, {@code defaultPositionProjectionUs} is non-zero, and the window's + * position could not be projected by {@code defaultPositionProjectionUs}. + */ + private Pair getPeriodPosition(Timeline timeline, int windowIndex, + long windowPositionUs, long defaultPositionProjectionUs) { + Assertions.checkIndex(windowIndex, 0, timeline.getWindowCount()); + timeline.getWindow(windowIndex, window, false, defaultPositionProjectionUs); + if (windowPositionUs == C.TIME_UNSET) { + windowPositionUs = window.getDefaultPositionUs(); + if (windowPositionUs == C.TIME_UNSET) { + return null; + } + } + int periodIndex = window.firstPeriodIndex; + long periodPositionUs = window.getPositionInFirstPeriodUs() + windowPositionUs; + long periodDurationUs = timeline.getPeriod(periodIndex, period).getDurationUs(); + while (periodDurationUs != C.TIME_UNSET && periodPositionUs >= periodDurationUs + && periodIndex < window.lastPeriodIndex) { + periodPositionUs -= periodDurationUs; + periodDurationUs = timeline.getPeriod(++periodIndex, period).getDurationUs(); } return Pair.create(periodIndex, periodPositionUs); } @@ -937,48 +1109,8 @@ import java.io.IOException; return; } - if (loadingPeriodHolder == null - || (loadingPeriodHolder.isFullyBuffered() && !loadingPeriodHolder.isLast - && bufferAheadPeriodCount < MAXIMUM_BUFFER_AHEAD_PERIODS)) { - // We don't have a loading period or it's fully loaded, so try and create the next one. - int newLoadingPeriodIndex = loadingPeriodHolder == null ? playbackInfo.periodIndex - : loadingPeriodHolder.index + 1; - if (newLoadingPeriodIndex >= timeline.getPeriodCount()) { - // The period is not available yet. - mediaSource.maybeThrowSourceInfoRefreshError(); - } else { - int windowIndex = timeline.getPeriod(newLoadingPeriodIndex, period).windowIndex; - boolean isFirstPeriodInWindow = newLoadingPeriodIndex - == timeline.getWindow(windowIndex, window).firstPeriodIndex; - long periodStartPositionUs = loadingPeriodHolder == null ? playbackInfo.positionUs - : (isFirstPeriodInWindow ? C.TIME_UNSET : 0); - if (periodStartPositionUs == C.TIME_UNSET) { - // This is the first period of a new window or we don't have a start position, so seek to - // the default position for the window. - Pair defaultPosition = getDefaultPosition(newLoadingPeriodIndex); - newLoadingPeriodIndex = defaultPosition.first; - periodStartPositionUs = defaultPosition.second; - } - Object newPeriodUid = timeline.getPeriod(newLoadingPeriodIndex, period, true).uid; - MediaPeriod newMediaPeriod = mediaSource.createPeriod(newLoadingPeriodIndex, - loadControl.getAllocator(), periodStartPositionUs); - newMediaPeriod.prepare(this); - MediaPeriodHolder newPeriodHolder = new MediaPeriodHolder<>(renderers, - rendererCapabilities, trackSelector, mediaSource, newMediaPeriod, newPeriodUid, - periodStartPositionUs); - timeline.getWindow(windowIndex, window); - newPeriodHolder.setIndex(timeline, window, newLoadingPeriodIndex); - if (loadingPeriodHolder != null) { - loadingPeriodHolder.setNext(newPeriodHolder); - newPeriodHolder.rendererPositionOffsetUs = loadingPeriodHolder.rendererPositionOffsetUs - + timeline.getPeriod(loadingPeriodHolder.index, period).getDurationUs(); - } - bufferAheadPeriodCount++; - loadingPeriodHolder = newPeriodHolder; - setIsLoading(true); - } - } - + // Update the loading period if required. + maybeUpdateLoadingPeriod(); if (loadingPeriodHolder == null || loadingPeriodHolder.isFullyBuffered()) { setIsLoading(false); } else if (loadingPeriodHolder != null && loadingPeriodHolder.needsContinueLoading) { @@ -991,24 +1123,25 @@ import java.io.IOException; } // Update the playing and reading periods. - while (playingPeriodHolder != readingPeriodHolder && playingPeriodHolder.next != null + while (playingPeriodHolder != readingPeriodHolder && rendererPositionUs >= playingPeriodHolder.next.rendererPositionOffsetUs) { // All enabled renderers' streams have been read to the end, and the playback position reached // the end of the playing period, so advance playback to the next period. playingPeriodHolder.release(); setPlayingPeriodHolder(playingPeriodHolder.next); - bufferAheadPeriodCount--; playbackInfo = new PlaybackInfo(playingPeriodHolder.index, playingPeriodHolder.startPositionUs); updatePlaybackPositions(); eventHandler.obtainMessage(MSG_POSITION_DISCONTINUITY, playbackInfo).sendToTarget(); } - updateTimelineState(); if (readingPeriodHolder.isLast) { - // The renderers have their final SampleStreams. for (Renderer renderer : enabledRenderers) { - renderer.setCurrentStreamIsFinal(); + // Defer setting the stream as final until the renderer has actually consumed the whole + // stream in case of playlist changes that cause the stream to be no longer final. + if (renderer.hasReadStreamToEnd()) { + renderer.setCurrentStreamFinal(); + } } return; } @@ -1018,59 +1151,136 @@ import java.io.IOException; return; } } + if (readingPeriodHolder.next != null && readingPeriodHolder.next.prepared) { - TrackSelections oldTrackSelections = readingPeriodHolder.trackSelections; + TrackSelectionArray oldTrackSelections = readingPeriodHolder.trackSelections; readingPeriodHolder = readingPeriodHolder.next; - TrackSelections newTrackSelections = readingPeriodHolder.trackSelections; + TrackSelectionArray newTrackSelections = readingPeriodHolder.trackSelections; + + boolean initialDiscontinuity = + readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET; for (int i = 0; i < renderers.length; i++) { Renderer renderer = renderers[i]; TrackSelection oldSelection = oldTrackSelections.get(i); TrackSelection newSelection = newTrackSelections.get(i); - if (oldSelection != null) { + if (oldSelection == null) { + // The renderer has no current stream and will be enabled when we play the next period. + } else if (initialDiscontinuity) { + // The new period starts with a discontinuity, so the renderer will play out all data then + // be disabled and re-enabled when it starts playing the next period. + renderer.setCurrentStreamFinal(); + } else if (!renderer.isCurrentStreamFinal()) { if (newSelection != null) { - // Replace the renderer's SampleStream so the transition to playing the next period can - // be seamless. + // Replace the renderer's SampleStream so the transition to playing the next period + // can be seamless. Format[] formats = new Format[newSelection.length()]; for (int j = 0; j < formats.length; j++) { formats[j] = newSelection.getFormat(j); } renderer.replaceStream(formats, readingPeriodHolder.sampleStreams[i], - readingPeriodHolder.rendererPositionOffsetUs); + readingPeriodHolder.getRendererOffset()); } else { // The renderer will be disabled when transitioning to playing the next period. Mark the // SampleStream as final to play out any remaining data. - renderer.setCurrentStreamIsFinal(); + renderer.setCurrentStreamFinal(); } } } } } + private void maybeUpdateLoadingPeriod() throws IOException { + int newLoadingPeriodIndex; + if (loadingPeriodHolder == null) { + newLoadingPeriodIndex = playbackInfo.periodIndex; + } else { + int loadingPeriodIndex = loadingPeriodHolder.index; + if (loadingPeriodHolder.isLast || !loadingPeriodHolder.isFullyBuffered() + || timeline.getPeriod(loadingPeriodIndex, period).getDurationUs() == C.TIME_UNSET) { + // Either the existing loading period is the last period, or we are not ready to advance to + // loading the next period because it hasn't been fully buffered or its duration is unknown. + return; + } + if (playingPeriodHolder != null + && loadingPeriodIndex - playingPeriodHolder.index == MAXIMUM_BUFFER_AHEAD_PERIODS) { + // We are already buffering the maximum number of periods ahead. + return; + } + newLoadingPeriodIndex = loadingPeriodHolder.index + 1; + } + + if (newLoadingPeriodIndex >= timeline.getPeriodCount()) { + // The next period is not available yet. + mediaSource.maybeThrowSourceInfoRefreshError(); + return; + } + + long newLoadingPeriodStartPositionUs; + if (loadingPeriodHolder == null) { + newLoadingPeriodStartPositionUs = playbackInfo.startPositionUs; + } else { + int newLoadingWindowIndex = timeline.getPeriod(newLoadingPeriodIndex, period).windowIndex; + if (newLoadingPeriodIndex + != timeline.getWindow(newLoadingWindowIndex, window).firstPeriodIndex) { + // We're starting to buffer a new period in the current window. Always start from the + // beginning of the period. + newLoadingPeriodStartPositionUs = 0; + } else { + // We're starting to buffer a new window. When playback transitions to this window we'll + // want it to be from its default start position. The expected delay until playback + // transitions is equal the duration of media that's currently buffered (assuming no + // interruptions). Hence we project the default start position forward by the duration of + // the buffer, and start buffering from this point. + long defaultPositionProjectionUs = loadingPeriodHolder.getRendererOffset() + + timeline.getPeriod(loadingPeriodHolder.index, period).getDurationUs() + - rendererPositionUs; + Pair defaultPosition = getPeriodPosition(timeline, newLoadingWindowIndex, + C.TIME_UNSET, Math.max(0, defaultPositionProjectionUs)); + if (defaultPosition == null) { + return; + } + + newLoadingPeriodIndex = defaultPosition.first; + newLoadingPeriodStartPositionUs = defaultPosition.second; + } + } + + long rendererPositionOffsetUs = loadingPeriodHolder == null + ? newLoadingPeriodStartPositionUs + RENDERER_TIMESTAMP_OFFSET_US + : (loadingPeriodHolder.getRendererOffset() + + timeline.getPeriod(loadingPeriodHolder.index, period).getDurationUs()); + timeline.getPeriod(newLoadingPeriodIndex, period, true); + boolean isLastPeriod = newLoadingPeriodIndex == timeline.getPeriodCount() - 1 + && !timeline.getWindow(period.windowIndex, window).isDynamic; + MediaPeriodHolder newPeriodHolder = new MediaPeriodHolder(renderers, rendererCapabilities, + rendererPositionOffsetUs, trackSelector, loadControl, mediaSource, period.uid, + newLoadingPeriodIndex, isLastPeriod, newLoadingPeriodStartPositionUs); + if (loadingPeriodHolder != null) { + loadingPeriodHolder.next = newPeriodHolder; + } + loadingPeriodHolder = newPeriodHolder; + loadingPeriodHolder.mediaPeriod.prepare(this); + setIsLoading(true); + } + private void handlePeriodPrepared(MediaPeriod period) throws ExoPlaybackException { if (loadingPeriodHolder == null || loadingPeriodHolder.mediaPeriod != period) { // Stale event. return; } - loadingPeriodHolder.handlePrepared(loadingPeriodHolder.startPositionUs, loadControl); + loadingPeriodHolder.handlePrepared(); if (playingPeriodHolder == null) { // This is the first prepared period, so start playing it. readingPeriodHolder = loadingPeriodHolder; + resetRendererPosition(readingPeriodHolder.startPositionUs); setPlayingPeriodHolder(readingPeriodHolder); - if (playbackInfo.startPositionUs == C.TIME_UNSET) { - // Update the playback info when seeking to a default position. - playbackInfo = new PlaybackInfo(playingPeriodHolder.index, - playingPeriodHolder.startPositionUs); - resetRendererPosition(playbackInfo.startPositionUs); - updatePlaybackPositions(); - eventHandler.obtainMessage(MSG_POSITION_DISCONTINUITY, playbackInfo).sendToTarget(); - } - updateTimelineState(); } maybeContinueLoading(); } private void handleContinueLoadingRequested(MediaPeriod period) { if (loadingPeriodHolder == null || loadingPeriodHolder.mediaPeriod != period) { + // Stale event. return; } maybeContinueLoading(); @@ -1078,9 +1288,10 @@ import java.io.IOException; private void maybeContinueLoading() { long nextLoadPositionUs = loadingPeriodHolder.mediaPeriod.getNextLoadPositionUs(); - if (nextLoadPositionUs != C.TIME_END_OF_SOURCE) { - long loadingPeriodPositionUs = rendererPositionUs - - loadingPeriodHolder.rendererPositionOffsetUs; + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE) { + setIsLoading(false); + } else { + long loadingPeriodPositionUs = loadingPeriodHolder.toPeriodTime(rendererPositionUs); long bufferedDurationUs = nextLoadPositionUs - loadingPeriodPositionUs; boolean continueLoading = loadControl.shouldContinueLoading(bufferedDurationUs); setIsLoading(continueLoading); @@ -1090,20 +1301,22 @@ import java.io.IOException; } else { loadingPeriodHolder.needsContinueLoading = true; } - } else { - setIsLoading(false); } } - private void releasePeriodHoldersFrom(MediaPeriodHolder periodHolder) { + private void releasePeriodHoldersFrom(MediaPeriodHolder periodHolder) { while (periodHolder != null) { periodHolder.release(); periodHolder = periodHolder.next; } } - private void setPlayingPeriodHolder(MediaPeriodHolder periodHolder) - throws ExoPlaybackException { + private void setPlayingPeriodHolder(MediaPeriodHolder periodHolder) throws ExoPlaybackException { + if (playingPeriodHolder == periodHolder) { + return; + } + + playingPeriodHolder = periodHolder; int enabledRendererCount = 0; boolean[] rendererWasEnabledFlags = new boolean[renderers.length]; for (int i = 0; i < renderers.length; i++) { @@ -1111,10 +1324,12 @@ import java.io.IOException; rendererWasEnabledFlags[i] = renderer.getState() != Renderer.STATE_DISABLED; TrackSelection newSelection = periodHolder.trackSelections.get(i); if (newSelection != null) { - // The renderer should be enabled when playing the new period. enabledRendererCount++; - } else if (rendererWasEnabledFlags[i]) { - // The renderer should be disabled when playing the new period. + } + if (rendererWasEnabledFlags[i] && (newSelection == null || renderer.isCurrentStreamFinal())) { + // The renderer should be disabled before playing the next period, either because it's not + // needed to play the next period, or because we need to disable and re-enable it because + // the renderer thinks that its current stream is final. if (renderer == rendererMediaClockSource) { // Sync standaloneMediaClock so that it can take over timing responsibilities. standaloneMediaClock.setPositionUs(rendererMediaClock.getPositionUs()); @@ -1126,20 +1341,10 @@ import java.io.IOException; } } - trackSelector.onSelectionActivated(periodHolder.trackSelections); - playingPeriodHolder = periodHolder; + eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.getTrackInfo()).sendToTarget(); enableRenderers(rendererWasEnabledFlags, enabledRendererCount); } - private void updateTimelineState() { - long playingPeriodDurationUs = timeline.getPeriod(playingPeriodHolder.index, period) - .getDurationUs(); - isTimelineReady = playingPeriodDurationUs == C.TIME_UNSET - || playbackInfo.positionUs < playingPeriodDurationUs - || (playingPeriodHolder.next != null && playingPeriodHolder.next.prepared); - isTimelineEnded = playingPeriodHolder.isLast; - } - private void enableRenderers(boolean[] rendererWasEnabledFlags, int enabledRendererCount) throws ExoPlaybackException { enabledRenderers = new Renderer[enabledRendererCount]; @@ -1161,7 +1366,7 @@ import java.io.IOException; } // Enable the renderer. renderer.enable(formats, playingPeriodHolder.sampleStreams[i], rendererPositionUs, - joining, playingPeriodHolder.rendererPositionOffsetUs); + joining, playingPeriodHolder.getRendererOffset()); MediaClock mediaClock = renderer.getMediaClock(); if (mediaClock != null) { if (rendererMediaClock != null) { @@ -1183,52 +1388,68 @@ import java.io.IOException; /** * Holds a {@link MediaPeriod} with information required to play it as part of a timeline. */ - private static final class MediaPeriodHolder { + private static final class MediaPeriodHolder { public final MediaPeriod mediaPeriod; public final Object uid; - public final SampleStream[] sampleStreams; public final boolean[] mayRetainStreamFlags; + public final long rendererPositionOffsetUs; public int index; public long startPositionUs; public boolean isLast; public boolean prepared; public boolean hasEnabledTracks; - public long rendererPositionOffsetUs; - public MediaPeriodHolder next; + public MediaPeriodHolder next; public boolean needsContinueLoading; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; - private final TrackSelector trackSelector; + private final TrackSelector trackSelector; + private final LoadControl loadControl; private final MediaSource mediaSource; - private TrackSelections trackSelections; - private TrackSelections periodTrackSelections; + private Object trackSelectionsInfo; + private TrackGroupArray trackGroups; + private TrackSelectionArray trackSelections; + private TrackSelectionArray periodTrackSelections; public MediaPeriodHolder(Renderer[] renderers, RendererCapabilities[] rendererCapabilities, - TrackSelector trackSelector, MediaSource mediaSource, MediaPeriod mediaPeriod, - Object uid, long positionUs) { + long rendererPositionOffsetUs, TrackSelector trackSelector, LoadControl loadControl, + MediaSource mediaSource, Object periodUid, int periodIndex, boolean isLastPeriod, + long startPositionUs) { this.renderers = renderers; this.rendererCapabilities = rendererCapabilities; + this.rendererPositionOffsetUs = rendererPositionOffsetUs; this.trackSelector = trackSelector; + this.loadControl = loadControl; this.mediaSource = mediaSource; - this.mediaPeriod = mediaPeriod; - this.uid = Assertions.checkNotNull(uid); + this.uid = Assertions.checkNotNull(periodUid); + this.index = periodIndex; + this.isLast = isLastPeriod; + this.startPositionUs = startPositionUs; sampleStreams = new SampleStream[renderers.length]; mayRetainStreamFlags = new boolean[renderers.length]; - startPositionUs = positionUs; + mediaPeriod = mediaSource.createPeriod(periodIndex, loadControl.getAllocator(), + startPositionUs); } - public void setNext(MediaPeriodHolder next) { - this.next = next; + public long toRendererTime(long periodTimeUs) { + return periodTimeUs + getRendererOffset(); } - public void setIndex(Timeline timeline, Timeline.Window window, int periodIndex) { - this.index = periodIndex; - isLast = index == timeline.getPeriodCount() - 1 && !window.isDynamic; + public long toPeriodTime(long rendererTimeUs) { + return rendererTimeUs - getRendererOffset(); + } + + public long getRendererOffset() { + return rendererPositionOffsetUs - startPositionUs; + } + + public void setIndex(int index, boolean isLast) { + this.index = index; + this.isLast = isLast; } public boolean isFullyBuffered() { @@ -1236,31 +1457,32 @@ import java.io.IOException; && (!hasEnabledTracks || mediaPeriod.getBufferedPositionUs() == C.TIME_END_OF_SOURCE); } - public void handlePrepared(long positionUs, LoadControl loadControl) - throws ExoPlaybackException { + public void handlePrepared() throws ExoPlaybackException { prepared = true; + trackGroups = mediaPeriod.getTrackGroups(); selectTracks(); - startPositionUs = updatePeriodTrackSelection(positionUs, loadControl, false); + startPositionUs = updatePeriodTrackSelection(startPositionUs, false); } public boolean selectTracks() throws ExoPlaybackException { - TrackSelections newTrackSelections = trackSelector.selectTracks(rendererCapabilities, - mediaPeriod.getTrackGroups()); + Pair selectorResult = trackSelector.selectTracks( + rendererCapabilities, trackGroups); + TrackSelectionArray newTrackSelections = selectorResult.first; if (newTrackSelections.equals(periodTrackSelections)) { return false; } trackSelections = newTrackSelections; + trackSelectionsInfo = selectorResult.second; return true; } - public long updatePeriodTrackSelection(long positionUs, LoadControl loadControl, - boolean forceRecreateStreams) throws ExoPlaybackException { - return updatePeriodTrackSelection(positionUs, loadControl, forceRecreateStreams, + public long updatePeriodTrackSelection(long positionUs, boolean forceRecreateStreams) { + return updatePeriodTrackSelection(positionUs, forceRecreateStreams, new boolean[renderers.length]); } - public long updatePeriodTrackSelection(long positionUs, LoadControl loadControl, - boolean forceRecreateStreams, boolean[] streamResetFlags) throws ExoPlaybackException { + public long updatePeriodTrackSelection(long positionUs, boolean forceRecreateStreams, + boolean[] streamResetFlags) { for (int i = 0; i < trackSelections.length; i++) { mayRetainStreamFlags[i] = !forceRecreateStreams && Util.areEqual(periodTrackSelections == null ? null : periodTrackSelections.get(i), @@ -1284,10 +1506,14 @@ import java.io.IOException; } // The track selection has changed. - loadControl.onTracksSelected(renderers, mediaPeriod.getTrackGroups(), trackSelections); + loadControl.onTracksSelected(renderers, trackGroups, trackSelections); return positionUs; } + public TrackInfo getTrackInfo() { + return new TrackInfo(trackGroups, trackSelections, trackSelectionsInfo); + } + public void release() { try { mediaSource.releasePeriod(mediaPeriod); @@ -1299,4 +1525,18 @@ import java.io.IOException; } + private static final class SeekPosition { + + public final Timeline timeline; + public final int windowIndex; + public final long windowPositionUs; + + public SeekPosition(Timeline timeline, int windowIndex, long windowPositionUs) { + this.timeline = timeline; + this.windowIndex = windowIndex; + this.windowPositionUs = windowPositionUs; + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 02c70bb0be..45f63d713d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -23,7 +23,7 @@ public interface ExoPlayerLibraryInfo { /** * The version of the library, expressed as a string. */ - String VERSION = "2.0.4"; + String VERSION = "2.1.0"; /** * The version of the library, expressed as an integer. @@ -32,7 +32,7 @@ public interface ExoPlayerLibraryInfo { * corresponding integer version 1002003 (001-002-003), and "123.45.6" has the corresponding * integer version 123045006 (123-045-006). */ - int VERSION_INT = 2000004; + int VERSION_INT = 2001000; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} @@ -45,5 +45,5 @@ public interface ExoPlayerLibraryInfo { * trace enabled. */ boolean TRACE_ENABLED = true; - + } diff --git a/library/src/main/java/com/google/android/exoplayer2/Format.java b/library/src/main/java/com/google/android/exoplayer2/Format.java index 550e6ab1d8..14efb6a2c7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/src/main/java/com/google/android/exoplayer2/Format.java @@ -21,6 +21,7 @@ import android.media.MediaFormat; import android.os.Parcel; import android.os.Parcelable; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; @@ -57,6 +58,10 @@ public final class Format implements Parcelable { * Codecs of the format as described in RFC 6381, or null if unknown or not applicable. */ public final String codecs; + /** + * Metadata, or null if unknown or not applicable. + */ + public final Metadata metadata; // Container specific. @@ -173,6 +178,11 @@ public final class Format implements Parcelable { */ public final String language; + /** + * The Accessibility channel, or {@link #NO_VALUE} if not known or applicable. + */ + public final int accessibilityChannel; + // Lazily initialized hashcode and framework media format. private int hashCode; @@ -185,7 +195,8 @@ public final class Format implements Parcelable { float frameRate, List initializationData) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, width, height, frameRate, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, initializationData, null); + NO_VALUE, NO_VALUE, 0, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, initializationData, null, + null); } public static Format createVideoSampleFormat(String id, String sampleMimeType, String codecs, @@ -210,8 +221,8 @@ public final class Format implements Parcelable { byte[] projectionData, @C.StereoMode int stereoMode, DrmInitData drmInitData) { return new Format(id, null, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, NO_VALUE, - NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, initializationData, - drmInitData); + NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, 0, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, + initializationData, drmInitData, null); } // Audio. @@ -221,8 +232,8 @@ public final class Format implements Parcelable { List initializationData, @C.SelectionFlags int selectionFlags, String language) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, channelCount, sampleRate, NO_VALUE, - NO_VALUE, NO_VALUE, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, initializationData, - null); + NO_VALUE, NO_VALUE, selectionFlags, language, NO_VALUE, OFFSET_SAMPLE_RELATIVE, + initializationData, null, null); } public static Format createAudioSampleFormat(String id, String sampleMimeType, String codecs, @@ -239,18 +250,18 @@ public final class Format implements Parcelable { @C.SelectionFlags int selectionFlags, String language) { return createAudioSampleFormat(id, sampleMimeType, codecs, bitrate, maxInputSize, channelCount, sampleRate, pcmEncoding, NO_VALUE, NO_VALUE, initializationData, drmInitData, - selectionFlags, language); + selectionFlags, language, null); } public static Format createAudioSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, int maxInputSize, int channelCount, int sampleRate, @C.PcmEncoding int pcmEncoding, int encoderDelay, int encoderPadding, List initializationData, DrmInitData drmInitData, - @C.SelectionFlags int selectionFlags, String language) { + @C.SelectionFlags int selectionFlags, String language, Metadata metadata) { return new Format(id, null, sampleMimeType, codecs, bitrate, maxInputSize, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, channelCount, sampleRate, pcmEncoding, - encoderDelay, encoderPadding, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, - initializationData, drmInitData); + encoderDelay, encoderPadding, selectionFlags, language, NO_VALUE, OFFSET_SAMPLE_RELATIVE, + initializationData, drmInitData, metadata); } // Text. @@ -258,23 +269,46 @@ public final class Format implements Parcelable { public static Format createTextContainerFormat(String id, String containerMimeType, String sampleMimeType, String codecs, int bitrate, @C.SelectionFlags int selectionFlags, String language) { + return createTextContainerFormat(id, containerMimeType, sampleMimeType, codecs, bitrate, + selectionFlags, language, NO_VALUE); + } + + public static Format createTextContainerFormat(String id, String containerMimeType, + String sampleMimeType, String codecs, int bitrate, @C.SelectionFlags int selectionFlags, + String language, int accessibilityChannel) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, selectionFlags, language, OFFSET_SAMPLE_RELATIVE, null, null); + NO_VALUE, NO_VALUE, selectionFlags, language, accessibilityChannel, + OFFSET_SAMPLE_RELATIVE, null, null, null); } public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, @C.SelectionFlags int selectionFlags, String language, DrmInitData drmInitData) { return createTextSampleFormat(id, sampleMimeType, codecs, bitrate, selectionFlags, language, - drmInitData, OFFSET_SAMPLE_RELATIVE); + NO_VALUE, drmInitData, OFFSET_SAMPLE_RELATIVE); + } + + public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, + int bitrate, @C.SelectionFlags int selectionFlags, String language, + int accessibilityChannel, DrmInitData drmInitData) { + return createTextSampleFormat(id, sampleMimeType, codecs, bitrate, selectionFlags, language, + accessibilityChannel, drmInitData, OFFSET_SAMPLE_RELATIVE); } public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, @C.SelectionFlags int selectionFlags, String language, DrmInitData drmInitData, long subsampleOffsetUs) { + return createTextSampleFormat(id, sampleMimeType, codecs, bitrate, selectionFlags, language, + NO_VALUE, drmInitData, subsampleOffsetUs); + } + + public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, + int bitrate, @C.SelectionFlags int selectionFlags, String language, + int accessibilityChannel, DrmInitData drmInitData, long subsampleOffsetUs) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, selectionFlags, language, subsampleOffsetUs, null, drmInitData); + NO_VALUE, selectionFlags, language, accessibilityChannel, subsampleOffsetUs, null, + drmInitData, null); } // Image. @@ -283,7 +317,8 @@ public final class Format implements Parcelable { int bitrate, List initializationData, String language, DrmInitData drmInitData) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, 0, language, OFFSET_SAMPLE_RELATIVE, initializationData, drmInitData); + NO_VALUE, 0, language, NO_VALUE, OFFSET_SAMPLE_RELATIVE, initializationData, drmInitData, + null); } // Generic. @@ -292,14 +327,14 @@ public final class Format implements Parcelable { String sampleMimeType, int bitrate) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, null); + NO_VALUE, NO_VALUE, 0, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, null, null, null); } public static Format createSampleFormat(String id, String sampleMimeType, String codecs, int bitrate, DrmInitData drmInitData) { return new Format(id, null, sampleMimeType, codecs, bitrate, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, null, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, 0, null, OFFSET_SAMPLE_RELATIVE, null, drmInitData); + NO_VALUE, 0, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, null, drmInitData, null); } /* package */ Format(String id, String containerMimeType, String sampleMimeType, String codecs, @@ -307,7 +342,8 @@ public final class Format implements Parcelable { float pixelWidthHeightRatio, byte[] projectionData, @C.StereoMode int stereoMode, int channelCount, int sampleRate, @C.PcmEncoding int pcmEncoding, int encoderDelay, int encoderPadding, @C.SelectionFlags int selectionFlags, String language, - long subsampleOffsetUs, List initializationData, DrmInitData drmInitData) { + int accessibilityChannel, long subsampleOffsetUs, List initializationData, + DrmInitData drmInitData, Metadata metadata) { this.id = id; this.containerMimeType = containerMimeType; this.sampleMimeType = sampleMimeType; @@ -328,10 +364,12 @@ public final class Format implements Parcelable { this.encoderPadding = encoderPadding; this.selectionFlags = selectionFlags; this.language = language; + this.accessibilityChannel = accessibilityChannel; this.subsampleOffsetUs = subsampleOffsetUs; this.initializationData = initializationData == null ? Collections.emptyList() : initializationData; this.drmInitData = drmInitData; + this.metadata = metadata; } @SuppressWarnings("ResourceType") @@ -357,6 +395,7 @@ public final class Format implements Parcelable { encoderPadding = in.readInt(); selectionFlags = in.readInt(); language = in.readString(); + accessibilityChannel = in.readInt(); subsampleOffsetUs = in.readLong(); int initializationDataSize = in.readInt(); initializationData = new ArrayList<>(initializationDataSize); @@ -364,20 +403,23 @@ public final class Format implements Parcelable { initializationData.add(in.createByteArray()); } drmInitData = in.readParcelable(DrmInitData.class.getClassLoader()); + metadata = in.readParcelable(Metadata.class.getClassLoader()); } public Format copyWithMaxInputSize(int maxInputSize) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); } public Format copyWithSubsampleOffsetUs(long subsampleOffsetUs) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); } public Format copyWithContainerInfo(String id, String codecs, int bitrate, int width, int height, @@ -385,7 +427,8 @@ public final class Format implements Parcelable { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); } public Format copyWithManifestFormatInfo(Format manifestFormat, @@ -401,21 +444,32 @@ public final class Format implements Parcelable { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, selectionFlags, - language, subsampleOffsetUs, initializationData, drmInitData); + language, accessibilityChannel, subsampleOffsetUs, initializationData, drmInitData, + metadata); } public Format copyWithGaplessInfo(int encoderDelay, int encoderPadding) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); } public Format copyWithDrmInitData(DrmInitData drmInitData) { return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, - selectionFlags, language, subsampleOffsetUs, initializationData, drmInitData); + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); + } + + public Format copyWithMetadata(Metadata metadata) { + return new Format(id, containerMimeType, sampleMimeType, codecs, bitrate, maxInputSize, + width, height, frameRate, rotationDegrees, pixelWidthHeightRatio, projectionData, + stereoMode, channelCount, sampleRate, pcmEncoding, encoderDelay, encoderPadding, + selectionFlags, language, accessibilityChannel, subsampleOffsetUs, initializationData, + drmInitData, metadata); } /** @@ -474,7 +528,9 @@ public final class Format implements Parcelable { result = 31 * result + channelCount; result = 31 * result + sampleRate; result = 31 * result + (language == null ? 0 : language.hashCode()); + result = 31 * result + accessibilityChannel; result = 31 * result + (drmInitData == null ? 0 : drmInitData.hashCode()); + result = 31 * result + (metadata == null ? 0 : metadata.hashCode()); hashCode = result; } return hashCode; @@ -498,10 +554,12 @@ public final class Format implements Parcelable { || encoderPadding != other.encoderPadding || subsampleOffsetUs != other.subsampleOffsetUs || selectionFlags != other.selectionFlags || !Util.areEqual(id, other.id) || !Util.areEqual(language, other.language) + || accessibilityChannel != other.accessibilityChannel || !Util.areEqual(containerMimeType, other.containerMimeType) || !Util.areEqual(sampleMimeType, other.sampleMimeType) || !Util.areEqual(codecs, other.codecs) || !Util.areEqual(drmInitData, other.drmInitData) + || !Util.areEqual(metadata, other.metadata) || !Arrays.equals(projectionData, other.projectionData) || initializationData.size() != other.initializationData.size()) { return false; @@ -567,6 +625,7 @@ public final class Format implements Parcelable { dest.writeInt(encoderPadding); dest.writeInt(selectionFlags); dest.writeString(language); + dest.writeInt(accessibilityChannel); dest.writeLong(subsampleOffsetUs); int initializationDataSize = initializationData.size(); dest.writeInt(initializationDataSize); @@ -574,6 +633,7 @@ public final class Format implements Parcelable { dest.writeByteArray(initializationData.get(i)); } dest.writeParcelable(drmInitData, 0); + dest.writeParcelable(metadata, 0); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/IllegalSeekPositionException.java b/library/src/main/java/com/google/android/exoplayer2/IllegalSeekPositionException.java new file mode 100644 index 0000000000..baa1cf3f79 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/IllegalSeekPositionException.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +/** + * Thrown when an attempt is made to seek to a position that does not exist in the player's + * {@link Timeline}. + */ +public final class IllegalSeekPositionException extends IllegalStateException { + + /** + * The {@link Timeline} in which the seek was attempted. + */ + public final Timeline timeline; + /** + * The index of the window being seeked to. + */ + public final int windowIndex; + /** + * The seek position in the specified window. + */ + public final long positionMs; + + /** + * @param timeline The {@link Timeline} in which the seek was attempted. + * @param windowIndex The index of the window being seeked to. + * @param positionMs The seek position in the specified window. + */ + public IllegalSeekPositionException(Timeline timeline, int windowIndex, long positionMs) { + this.timeline = timeline; + this.windowIndex = windowIndex; + this.positionMs = positionMs; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/LoadControl.java b/library/src/main/java/com/google/android/exoplayer2/LoadControl.java index 6176c6085b..c092480222 100644 --- a/library/src/main/java/com/google/android/exoplayer2/LoadControl.java +++ b/library/src/main/java/com/google/android/exoplayer2/LoadControl.java @@ -17,7 +17,7 @@ package com.google.android.exoplayer2; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.TrackSelections; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; /** @@ -38,7 +38,7 @@ public interface LoadControl { * @param trackSelections The track selections that were made. */ void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroups, - TrackSelections trackSelections); + TrackSelectionArray trackSelections); /** * Called by the player when stopped. diff --git a/library/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/src/main/java/com/google/android/exoplayer2/Renderer.java index f5bc9141e1..b610a64bea 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -149,7 +149,13 @@ public interface Renderer extends ExoPlayerComponent { * This method may be called when the renderer is in the following states: * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. */ - void setCurrentStreamIsFinal(); + void setCurrentStreamFinal(); + + /** + * Returns whether the current {@link SampleStream} will be the final one supplied before the + * renderer is next disabled or reset. + */ + boolean isCurrentStreamFinal(); /** * Throws an error that's preventing the renderer from reading from its {@link SampleStream}. Does diff --git a/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 4b673d3750..36753309e2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -18,10 +18,10 @@ package com.google.android.exoplayer2; import android.annotation.TargetApi; import android.content.Context; import android.graphics.SurfaceTexture; -import android.media.AudioManager; import android.media.MediaCodec; import android.media.PlaybackParams; import android.os.Handler; +import android.support.annotation.IntDef; import android.util.Log; import android.view.Surface; import android.view.SurfaceHolder; @@ -35,16 +35,19 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; -import com.google.android.exoplayer2.metadata.id3.Id3Frame; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextRenderer; -import com.google.android.exoplayer2.trackselection.TrackSelections; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.List; @@ -54,7 +57,7 @@ import java.util.List; * be obtained from {@link ExoPlayerFactory}. */ @TargetApi(16) -public final class SimpleExoPlayer implements ExoPlayer { +public class SimpleExoPlayer implements ExoPlayer { /** * A listener for video rendering information from a {@link SimpleExoPlayer}. @@ -86,15 +89,35 @@ public final class SimpleExoPlayer implements ExoPlayer { */ void onRenderedFirstFrame(); - /** - * Called when a video track is no longer selected. - */ - void onVideoTracksDisabled(); - } + /** + * Modes for using extension renderers. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({EXTENSION_RENDERER_MODE_OFF, EXTENSION_RENDERER_MODE_ON, EXTENSION_RENDERER_MODE_PREFER}) + public @interface ExtensionRendererMode {} + /** + * Do not allow use of extension renderers. + */ + public static final int EXTENSION_RENDERER_MODE_OFF = 0; + /** + * Allow use of extension renderers. Extension renderers are indexed after core renderers of the + * same type. A {@link TrackSelector} that prefers the first suitable renderer will therefore + * prefer to use a core renderer to an extension renderer in the case that both are able to play + * a given track. + */ + public static final int EXTENSION_RENDERER_MODE_ON = 1; + /** + * Allow use of extension renderers. Extension renderers are indexed before core renderers of the + * same type. A {@link TrackSelector} that prefers the first suitable renderer will therefore + * prefer to use an extension renderer to a core renderer in the case that both are able to play + * a given track. + */ + public static final int EXTENSION_RENDERER_MODE_PREFER = 2; + private static final String TAG = "SimpleExoPlayer"; - private static final int MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY = 50; + protected static final int MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY = 50; private final ExoPlayer player; private final Renderer[] renderers; @@ -103,41 +126,38 @@ public final class SimpleExoPlayer implements ExoPlayer { private final int videoRendererCount; private final int audioRendererCount; - private boolean videoTracksEnabled; private Format videoFormat; private Format audioFormat; private Surface surface; private boolean ownsSurface; + @C.VideoScalingMode + private int videoScalingMode; private SurfaceHolder surfaceHolder; private TextureView textureView; private TextRenderer.Output textOutput; - private MetadataRenderer.Output> id3Output; + private MetadataRenderer.Output metadataOutput; private VideoListener videoListener; private AudioRendererEventListener audioDebugListener; private VideoRendererEventListener videoDebugListener; private DecoderCounters videoDecoderCounters; private DecoderCounters audioDecoderCounters; private int audioSessionId; - private float volume; + @C.StreamType + private int audioStreamType; + private float audioVolume; private PlaybackParamsHolder playbackParamsHolder; - /* package */ SimpleExoPlayer(Context context, TrackSelector trackSelector, - LoadControl loadControl, DrmSessionManager drmSessionManager, - boolean preferExtensionDecoders, long allowedVideoJoiningTimeMs) { + protected SimpleExoPlayer(Context context, TrackSelector trackSelector, LoadControl loadControl, + DrmSessionManager drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, long allowedVideoJoiningTimeMs) { mainHandler = new Handler(); componentListener = new ComponentListener(); - trackSelector.addListener(componentListener); // Build the renderers. ArrayList renderersList = new ArrayList<>(); - if (preferExtensionDecoders) { - buildExtensionRenderers(renderersList, allowedVideoJoiningTimeMs); - buildRenderers(context, drmSessionManager, renderersList, allowedVideoJoiningTimeMs); - } else { - buildRenderers(context, drmSessionManager, renderersList, allowedVideoJoiningTimeMs); - buildExtensionRenderers(renderersList, allowedVideoJoiningTimeMs); - } + buildRenderers(context, mainHandler, drmSessionManager, extensionRendererMode, + allowedVideoJoiningTimeMs, renderersList); renderers = renderersList.toArray(new Renderer[renderersList.size()]); // Obtain counts of video and audio renderers. @@ -157,31 +177,41 @@ public final class SimpleExoPlayer implements ExoPlayer { this.audioRendererCount = audioRendererCount; // Set initial values. + audioVolume = 1; audioSessionId = AudioTrack.SESSION_ID_NOT_SET; - volume = 1; + audioStreamType = C.STREAM_TYPE_DEFAULT; + videoScalingMode = C.VIDEO_SCALING_MODE_DEFAULT; // Build the player and associated objects. player = new ExoPlayerImpl(renderers, trackSelector, loadControl); } /** - * Returns the number of renderers. + * Sets the video scaling mode. + *

+ * Note that the scaling mode only applies if a {@link MediaCodec}-based video {@link Renderer} is + * enabled and if the output surface is owned by a {@link android.view.SurfaceView}. * - * @return The number of renderers. + * @param videoScalingMode The video scaling mode. */ - public int getRendererCount() { - return renderers.length; + public void setVideoScalingMode(@C.VideoScalingMode int videoScalingMode) { + this.videoScalingMode = videoScalingMode; + ExoPlayerMessage[] messages = new ExoPlayerMessage[videoRendererCount]; + int count = 0; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + messages[count++] = new ExoPlayerMessage(renderer, C.MSG_SET_SCALING_MODE, + videoScalingMode); + } + } + player.sendMessages(messages); } /** - * Returns the track type that the renderer at a given index handles. - * - * @see Renderer#getTrackType() - * @param index The index of the renderer. - * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + * Returns the video scaling mode. */ - public int getRendererType(int index) { - return renderers[index].getTrackType(); + public @C.VideoScalingMode int getVideoScalingMode() { + return videoScalingMode; } /** @@ -259,17 +289,47 @@ public final class SimpleExoPlayer implements ExoPlayer { } /** - * Sets the audio volume, with 0 being silence and 1 being unity gain. + * Sets the stream type for audio playback (see {@link C.StreamType} and + * {@link android.media.AudioTrack#AudioTrack(int, int, int, int, int, int)}). If the stream type + * is not set, audio renderers use {@link C#STREAM_TYPE_DEFAULT}. + *

+ * Note that when the stream type changes, the AudioTrack must be reinitialized, which can + * introduce a brief gap in audio output. Note also that tracks in the same audio session must + * share the same routing, so a new audio session id will be generated. * - * @param volume The volume. + * @param audioStreamType The stream type for audio playback. */ - public void setVolume(float volume) { - this.volume = volume; + public void setAudioStreamType(@C.StreamType int audioStreamType) { + this.audioStreamType = audioStreamType; ExoPlayerMessage[] messages = new ExoPlayerMessage[audioRendererCount]; int count = 0; for (Renderer renderer : renderers) { if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - messages[count++] = new ExoPlayerMessage(renderer, C.MSG_SET_VOLUME, volume); + messages[count++] = new ExoPlayerMessage(renderer, C.MSG_SET_STREAM_TYPE, audioStreamType); + } + } + player.sendMessages(messages); + } + + /** + * Returns the stream type for audio playback. + */ + public @C.StreamType int getAudioStreamType() { + return audioStreamType; + } + + /** + * Sets the audio volume, with 0 being silence and 1 being unity gain. + * + * @param audioVolume The audio volume. + */ + public void setVolume(float audioVolume) { + this.audioVolume = audioVolume; + ExoPlayerMessage[] messages = new ExoPlayerMessage[audioRendererCount]; + int count = 0; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { + messages[count++] = new ExoPlayerMessage(renderer, C.MSG_SET_VOLUME, audioVolume); } } player.sendMessages(messages); @@ -279,7 +339,7 @@ public final class SimpleExoPlayer implements ExoPlayer { * Returns the audio volume, with 0 being silence and 1 being unity gain. */ public float getVolume() { - return volume; + return audioVolume; } /** @@ -390,12 +450,21 @@ public final class SimpleExoPlayer implements ExoPlayer { } /** - * Sets a listener to receive ID3 metadata events. + * @deprecated Use {@link #setMetadataOutput(MetadataRenderer.Output)} instead. + * @param output The output. + */ + @Deprecated + public void setId3Output(MetadataRenderer.Output output) { + setMetadataOutput(output); + } + + /** + * Sets a listener to receive metadata events. * * @param output The output. */ - public void setId3Output(MetadataRenderer.Output> output) { - id3Output = output; + public void setMetadataOutput(MetadataRenderer.Output output) { + metadataOutput = output; } // ExoPlayer implementation @@ -517,6 +586,26 @@ public final class SimpleExoPlayer implements ExoPlayer { return player.getBufferedPercentage(); } + @Override + public int getRendererCount() { + return player.getRendererCount(); + } + + @Override + public int getRendererType(int index) { + return player.getRendererType(index); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return player.getCurrentTrackGroups(); + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return player.getCurrentTrackSelections(); + } + @Override public Timeline getCurrentTimeline() { return player.getCurrentTimeline(); @@ -527,55 +616,99 @@ public final class SimpleExoPlayer implements ExoPlayer { return player.getCurrentManifest(); } - // Internal methods. + // Renderer building. - private void buildRenderers(Context context, - DrmSessionManager drmSessionManager, ArrayList renderersList, - long allowedVideoJoiningTimeMs) { - MediaCodecVideoRenderer videoRenderer = new MediaCodecVideoRenderer(context, - MediaCodecSelector.DEFAULT, MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, - allowedVideoJoiningTimeMs, drmSessionManager, false, mainHandler, componentListener, - MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - renderersList.add(videoRenderer); - - Renderer audioRenderer = new MediaCodecAudioRenderer(MediaCodecSelector.DEFAULT, - drmSessionManager, true, mainHandler, componentListener, - AudioCapabilities.getCapabilities(context), AudioManager.STREAM_MUSIC); - renderersList.add(audioRenderer); - - Renderer textRenderer = new TextRenderer(componentListener, mainHandler.getLooper()); - renderersList.add(textRenderer); - - MetadataRenderer> id3Renderer = new MetadataRenderer<>(componentListener, - mainHandler.getLooper(), new Id3Decoder()); - renderersList.add(id3Renderer); + private void buildRenderers(Context context, Handler mainHandler, + DrmSessionManager drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, long allowedVideoJoiningTimeMs, + ArrayList out) { + buildVideoRenderers(context, mainHandler, drmSessionManager, extensionRendererMode, + componentListener, allowedVideoJoiningTimeMs, out); + buildAudioRenderers(context, mainHandler, drmSessionManager, extensionRendererMode, + componentListener, out); + buildTextRenderers(context, mainHandler, extensionRendererMode, componentListener, out); + buildMetadataRenderers(context, mainHandler, extensionRendererMode, componentListener, out); + buildMiscellaneousRenderers(context, mainHandler, extensionRendererMode, out); } - private void buildExtensionRenderers(ArrayList renderersList, - long allowedVideoJoiningTimeMs) { - // Load extension renderers using reflection so that demo app doesn't depend on them. - // Class.forName() appears for each renderer so that automated tools like proguard - // can detect the use of reflection (see http://proguard.sourceforge.net/FAQ.html#forname). + /** + * Builds video renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param mainHandler A handler associated with the main thread's looper. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param extensionRendererMode The extension renderer mode. + * @param eventListener An event listener. + * @param allowedVideoJoiningTimeMs The maximum duration in milliseconds for which video renderers + * can attempt to seamlessly join an ongoing playback. + * @param out An array to which the built renderers should be appended. + */ + protected void buildVideoRenderers(Context context, Handler mainHandler, + DrmSessionManager drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, ArrayList out) { + out.add(new MediaCodecVideoRenderer(context, MediaCodecSelector.DEFAULT, + allowedVideoJoiningTimeMs, drmSessionManager, false, mainHandler, eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return; + } + int extensionRendererIndex = out.size(); + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex--; + } + try { Class clazz = Class.forName("com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer"); Constructor constructor = clazz.getConstructor(boolean.class, long.class, Handler.class, VideoRendererEventListener.class, int.class); - renderersList.add((Renderer) constructor.newInstance(true, allowedVideoJoiningTimeMs, - mainHandler, componentListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + Renderer renderer = (Renderer) constructor.newInstance(true, allowedVideoJoiningTimeMs, + mainHandler, componentListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); + out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibvpxVideoRenderer."); } catch (ClassNotFoundException e) { // Expected if the app was built without the extension. } catch (Exception e) { throw new RuntimeException(e); } + } + + /** + * Builds audio renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param mainHandler A handler associated with the main thread's looper. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param extensionRendererMode The extension renderer mode. + * @param eventListener An event listener. + * @param out An array to which the built renderers should be appended. + */ + protected void buildAudioRenderers(Context context, Handler mainHandler, + DrmSessionManager drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, AudioRendererEventListener eventListener, + ArrayList out) { + out.add(new MediaCodecAudioRenderer(MediaCodecSelector.DEFAULT, drmSessionManager, true, + mainHandler, eventListener, AudioCapabilities.getCapabilities(context))); + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return; + } + int extensionRendererIndex = out.size(); + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex--; + } try { Class clazz = Class.forName("com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer"); Constructor constructor = clazz.getConstructor(Handler.class, AudioRendererEventListener.class); - renderersList.add((Renderer) constructor.newInstance(mainHandler, componentListener)); + Renderer renderer = (Renderer) constructor.newInstance(mainHandler, componentListener); + out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibopusAudioRenderer."); } catch (ClassNotFoundException e) { // Expected if the app was built without the extension. @@ -588,7 +721,8 @@ public final class SimpleExoPlayer implements ExoPlayer { Class.forName("com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer"); Constructor constructor = clazz.getConstructor(Handler.class, AudioRendererEventListener.class); - renderersList.add((Renderer) constructor.newInstance(mainHandler, componentListener)); + Renderer renderer = (Renderer) constructor.newInstance(mainHandler, componentListener); + out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibflacAudioRenderer."); } catch (ClassNotFoundException e) { // Expected if the app was built without the extension. @@ -601,7 +735,8 @@ public final class SimpleExoPlayer implements ExoPlayer { Class.forName("com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer"); Constructor constructor = clazz.getConstructor(Handler.class, AudioRendererEventListener.class); - renderersList.add((Renderer) constructor.newInstance(mainHandler, componentListener)); + Renderer renderer = (Renderer) constructor.newInstance(mainHandler, componentListener); + out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded FfmpegAudioRenderer."); } catch (ClassNotFoundException e) { // Expected if the app was built without the extension. @@ -610,6 +745,51 @@ public final class SimpleExoPlayer implements ExoPlayer { } } + /** + * Builds text renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param mainHandler A handler associated with the main thread's looper. + * @param extensionRendererMode The extension renderer mode. + * @param output An output for the renderers. + * @param out An array to which the built renderers should be appended. + */ + protected void buildTextRenderers(Context context, Handler mainHandler, + @ExtensionRendererMode int extensionRendererMode, TextRenderer.Output output, + ArrayList out) { + out.add(new TextRenderer(output, mainHandler.getLooper())); + } + + /** + * Builds metadata renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param mainHandler A handler associated with the main thread's looper. + * @param extensionRendererMode The extension renderer mode. + * @param output An output for the renderers. + * @param out An array to which the built renderers should be appended. + */ + protected void buildMetadataRenderers(Context context, Handler mainHandler, + @ExtensionRendererMode int extensionRendererMode, MetadataRenderer.Output output, + ArrayList out) { + out.add(new MetadataRenderer(output, mainHandler.getLooper(), new Id3Decoder())); + } + + /** + * Builds any miscellaneous renderers used by the player. + * + * @param context The {@link Context} associated with the player. + * @param mainHandler A handler associated with the main thread's looper. + * @param extensionRendererMode The extension renderer mode. + * @param out An array to which the built renderers should be appended. + */ + protected void buildMiscellaneousRenderers(Context context, Handler mainHandler, + @ExtensionRendererMode int extensionRendererMode, ArrayList out) { + // Do nothing. + } + + // Internal methods. + private void removeSurfaceCallbacks() { if (textureView != null) { if (textureView.getSurfaceTextureListener() != componentListener) { @@ -650,9 +830,8 @@ public final class SimpleExoPlayer implements ExoPlayer { } private final class ComponentListener implements VideoRendererEventListener, - AudioRendererEventListener, TextRenderer.Output, MetadataRenderer.Output>, - SurfaceHolder.Callback, TextureView.SurfaceTextureListener, - TrackSelector.EventListener { + AudioRendererEventListener, TextRenderer.Output, MetadataRenderer.Output, + SurfaceHolder.Callback, TextureView.SurfaceTextureListener { // VideoRendererEventListener implementation @@ -782,12 +961,12 @@ public final class SimpleExoPlayer implements ExoPlayer { } } - // MetadataRenderer.Output> implementation + // MetadataRenderer.Output implementation @Override - public void onMetadata(List id3Frames) { - if (id3Output != null) { - id3Output.onMetadata(id3Frames); + public void onMetadata(Metadata metadata) { + if (metadataOutput != null) { + metadataOutput.onMetadata(metadata); } } @@ -831,23 +1010,6 @@ public final class SimpleExoPlayer implements ExoPlayer { // Do nothing. } - // TrackSelector.EventListener implementation - - @Override - public void onTrackSelectionsChanged(TrackSelections trackSelections) { - boolean videoTracksEnabled = false; - for (int i = 0; i < renderers.length; i++) { - if (renderers[i].getTrackType() == C.TRACK_TYPE_VIDEO && trackSelections.get(i) != null) { - videoTracksEnabled = true; - break; - } - } - if (videoListener != null && SimpleExoPlayer.this.videoTracksEnabled && !videoTracksEnabled) { - videoListener.onVideoTracksDisabled(); - } - SimpleExoPlayer.this.videoTracksEnabled = videoTracksEnabled; - } - } @TargetApi(23) diff --git a/library/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/src/main/java/com/google/android/exoplayer2/Timeline.java index b394ecabf8..333dd25cbe 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -91,6 +91,46 @@ package com.google.android.exoplayer2; */ public abstract class Timeline { + /** + * An empty timeline. + */ + public static final Timeline EMPTY = new Timeline() { + + @Override + public int getWindowCount() { + return 0; + } + + @Override + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { + throw new IndexOutOfBoundsException(); + } + + @Override + public int getPeriodCount() { + return 0; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + throw new IndexOutOfBoundsException(); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return C.INDEX_UNSET; + } + + }; + + /** + * Returns whether the timeline is empty. + */ + public final boolean isEmpty() { + return getWindowCount() == 0; + } + /** * Returns the number of windows in the timeline. */ @@ -114,10 +154,26 @@ public abstract class Timeline { * @param windowIndex The index of the window. * @param window The {@link Window} to populate. Must not be null. * @param setIds Whether {@link Window#id} should be populated. If false, the field will be set to - * null. The caller should pass false for efficiency reasons unless the field is required. + * null. The caller should pass false for efficiency reasons unless the field is required. * @return The populated {@link Window}, for convenience. */ - public abstract Window getWindow(int windowIndex, Window window, boolean setIds); + public Window getWindow(int windowIndex, Window window, boolean setIds) { + return getWindow(windowIndex, window, setIds, 0); + } + + /** + * Populates a {@link Window} with data for the window at the specified index. + * + * @param windowIndex The index of the window. + * @param window The {@link Window} to populate. Must not be null. + * @param setIds Whether {@link Window#id} should be populated. If false, the field will be set to + * null. The caller should pass false for efficiency reasons unless the field is required. + * @param defaultPositionProjectionUs A duration into the future that the populated window's + * default start position should be projected. + * @return The populated {@link Window}, for convenience. + */ + public abstract Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs); /** * Returns the number of periods in the timeline. @@ -181,8 +237,8 @@ public abstract class Timeline { public long presentationStartTimeMs; /** - * The windows start time in milliseconds since the epoch, or {@link C#TIME_UNSET} if unknown or - * not applicable. For informational purposes only. + * The window's start time in milliseconds since the epoch, or {@link C#TIME_UNSET} if unknown + * or not applicable. For informational purposes only. */ public long windowStartTimeMs; @@ -206,9 +262,24 @@ public abstract class Timeline { */ public int lastPeriodIndex; - private long defaultPositionUs; - private long durationUs; - private long positionInFirstPeriodUs; + /** + * The default position relative to the start of the window at which to begin playback, in + * microseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. + */ + public long defaultPositionUs; + + /** + * The duration of this window in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long durationUs; + + /** + * The position of the start of this window relative to the start of the first period belonging + * to it, in microseconds. + */ + public long positionInFirstPeriodUs; /** * Sets the data held by this window. @@ -231,7 +302,9 @@ public abstract class Timeline { /** * Returns the default position relative to the start of the window at which to begin playback, - * in milliseconds. + * in milliseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. */ public long getDefaultPositionMs() { return C.usToMs(defaultPositionUs); @@ -239,7 +312,9 @@ public abstract class Timeline { /** * Returns the default position relative to the start of the window at which to begin playback, - * in microseconds. + * in microseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. */ public long getDefaultPositionUs() { return defaultPositionUs; @@ -303,7 +378,11 @@ public abstract class Timeline { */ public int windowIndex; - private long durationUs; + /** + * The duration of this period in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long durationUs; + private long positionInWindowUs; /** diff --git a/library/src/main/java/com/google/android/exoplayer2/audio/AudioDecoderException.java b/library/src/main/java/com/google/android/exoplayer2/audio/AudioDecoderException.java index d0ad44f8da..b5ee052924 100644 --- a/library/src/main/java/com/google/android/exoplayer2/audio/AudioDecoderException.java +++ b/library/src/main/java/com/google/android/exoplayer2/audio/AudioDecoderException.java @@ -27,4 +27,15 @@ public abstract class AudioDecoderException extends Exception { super(detailMessage); } + /** + * @param detailMessage The detail message for this exception. + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + */ + public AudioDecoderException(String detailMessage, Throwable cause) { + super(detailMessage, cause); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/audio/AudioTrack.java b/library/src/main/java/com/google/android/exoplayer2/audio/AudioTrack.java index 87f6546e1f..8e6cf68dc8 100644 --- a/library/src/main/java/com/google/android/exoplayer2/audio/AudioTrack.java +++ b/library/src/main/java/com/google/android/exoplayer2/audio/AudioTrack.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.audio; +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.media.AudioFormat; import android.media.AudioTimestamp; @@ -23,6 +24,7 @@ import android.os.ConditionVariable; import android.os.SystemClock; import android.util.Log; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; @@ -53,6 +55,24 @@ import java.nio.ByteBuffer; */ public final class AudioTrack { + /** + * Listener for audio track events. + */ + public interface Listener { + + /** + * Called when the audio track underruns. + * + * @param bufferSize The size of the track's buffer, in bytes. + * @param bufferSizeMs The size of the track's buffer, in milliseconds, if it is configured for + * PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, as the + * buffered media can have a variable bitrate so the duration may be unknown. + * @param elapsedSinceLastFeedMs The time since the track was last fed data, in milliseconds. + */ + void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs); + + } + /** * Thrown when a failure occurs initializing an {@link android.media.AudioTrack}. */ @@ -151,6 +171,40 @@ public final class AudioTrack { */ private static final int BUFFER_MULTIPLICATION_FACTOR = 4; + /** + * @see android.media.AudioTrack#PLAYSTATE_STOPPED + */ + private static final int PLAYSTATE_STOPPED = android.media.AudioTrack.PLAYSTATE_STOPPED; + /** + * @see android.media.AudioTrack#PLAYSTATE_PAUSED + */ + private static final int PLAYSTATE_PAUSED = android.media.AudioTrack.PLAYSTATE_PAUSED; + /** + * @see android.media.AudioTrack#PLAYSTATE_PLAYING + */ + private static final int PLAYSTATE_PLAYING = android.media.AudioTrack.PLAYSTATE_PLAYING; + /** + * @see android.media.AudioTrack#ERROR_BAD_VALUE + */ + private static final int ERROR_BAD_VALUE = android.media.AudioTrack.ERROR_BAD_VALUE; + /** + * @see android.media.AudioTrack#MODE_STATIC + */ + private static final int MODE_STATIC = android.media.AudioTrack.MODE_STATIC; + /** + * @see android.media.AudioTrack#MODE_STREAM + */ + private static final int MODE_STREAM = android.media.AudioTrack.MODE_STREAM; + /** + * @see android.media.AudioTrack#STATE_INITIALIZED + */ + private static final int STATE_INITIALIZED = android.media.AudioTrack.STATE_INITIALIZED; + /** + * @see android.media.AudioTrack#WRITE_NON_BLOCKING + */ + @SuppressLint("InlinedApi") + private static final int WRITE_NON_BLOCKING = android.media.AudioTrack.WRITE_NON_BLOCKING; + private static final String TAG = "AudioTrack"; /** @@ -195,7 +249,7 @@ public final class AudioTrack { public static boolean failOnSpuriousAudioTimestamp = false; private final AudioCapabilities audioCapabilities; - private final int streamType; + private final Listener listener; private final ConditionVariable releasingConditionVariable; private final long[] playheadOffsets; private final AudioTrackUtil audioTrackUtil; @@ -208,6 +262,8 @@ public final class AudioTrack { private android.media.AudioTrack audioTrack; private int sampleRate; private int channelConfig; + @C.StreamType + private int streamType; @C.Encoding private int sourceEncoding; @C.Encoding @@ -241,13 +297,16 @@ public final class AudioTrack { private ByteBuffer resampledBuffer; private boolean useResampledBuffer; + private boolean hasData; + private long lastFeedElapsedRealtimeMs; + /** * @param audioCapabilities The current audio capabilities. - * @param streamType The type of audio stream for the underlying {@link android.media.AudioTrack}. + * @param listener Listener for audio track events. */ - public AudioTrack(AudioCapabilities audioCapabilities, int streamType) { + public AudioTrack(AudioCapabilities audioCapabilities, Listener listener) { this.audioCapabilities = audioCapabilities; - this.streamType = streamType; + this.listener = listener; releasingConditionVariable = new ConditionVariable(true); if (Util.SDK_INT >= 18) { try { @@ -267,6 +326,7 @@ public final class AudioTrack { playheadOffsets = new long[MAX_PLAYHEAD_OFFSET_COUNT]; volume = 1.0f; startMediaTimeState = START_NOT_SET; + streamType = C.STREAM_TYPE_DEFAULT; } /** @@ -304,7 +364,7 @@ public final class AudioTrack { return CURRENT_POSITION_NOT_SET; } - if (audioTrack.getPlayState() == android.media.AudioTrack.PLAYSTATE_PLAYING) { + if (audioTrack.getPlayState() == PLAYSTATE_PLAYING) { maybeSampleSyncParams(); } @@ -423,7 +483,7 @@ public final class AudioTrack { } else { int minBufferSize = android.media.AudioTrack.getMinBufferSize(sampleRate, channelConfig, targetEncoding); - Assertions.checkState(minBufferSize != android.media.AudioTrack.ERROR_BAD_VALUE); + Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; int minAppBufferSize = (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * pcmFrameSize; int maxAppBufferSize = (int) Math.max(minBufferSize, @@ -452,11 +512,11 @@ public final class AudioTrack { if (sessionId == SESSION_ID_NOT_SET) { audioTrack = new android.media.AudioTrack(streamType, sampleRate, channelConfig, - targetEncoding, bufferSize, android.media.AudioTrack.MODE_STREAM); + targetEncoding, bufferSize, MODE_STREAM); } else { // Re-attach to the same audio session. audioTrack = new android.media.AudioTrack(streamType, sampleRate, channelConfig, - targetEncoding, bufferSize, android.media.AudioTrack.MODE_STREAM, sessionId); + targetEncoding, bufferSize, MODE_STREAM, sessionId); } checkAudioTrackInitialized(); @@ -475,42 +535,17 @@ public final class AudioTrack { @C.PcmEncoding int encoding = C.ENCODING_PCM_16BIT; int bufferSize = 2; // Use a two byte buffer, as it is not actually used for playback. keepSessionIdAudioTrack = new android.media.AudioTrack(streamType, sampleRate, - channelConfig, encoding, bufferSize, android.media.AudioTrack.MODE_STATIC, sessionId); + channelConfig, encoding, bufferSize, MODE_STATIC, sessionId); } } } audioTrackUtil.reconfigure(audioTrack, needsPassthroughWorkarounds()); setAudioTrackVolume(); + hasData = false; return sessionId; } - /** - * Returns the size of this {@link AudioTrack}'s buffer in bytes. - *

- * The value returned from this method may change as a result of calling one of the - * {@link #configure} methods. - * - * @return The size of the buffer in bytes. - */ - public int getBufferSize() { - return bufferSize; - } - - /** - * Returns the size of the buffer in microseconds for PCM {@link AudioTrack}s, or - * {@link C#TIME_UNSET} for passthrough {@link AudioTrack}s. - *

- * The value returned from this method may change as a result of calling one of the - * {@link #configure} methods. - * - * @return The size of the buffer in microseconds for PCM {@link AudioTrack}s, or - * {@link C#TIME_UNSET} for passthrough {@link AudioTrack}s. - */ - public long getBufferSizeUs() { - return bufferSizeUs; - } - /** * Starts or resumes playing audio if the audio track has been initialized. */ @@ -552,6 +587,18 @@ public final class AudioTrack { * @throws WriteException If an error occurs writing the audio data. */ public int handleBuffer(ByteBuffer buffer, long presentationTimeUs) throws WriteException { + boolean hadData = hasData; + hasData = hasPendingData(); + if (hadData && !hasData && audioTrack.getPlayState() != PLAYSTATE_STOPPED) { + long elapsedSinceLastFeedMs = SystemClock.elapsedRealtime() - lastFeedElapsedRealtimeMs; + listener.onUnderrun(bufferSize, C.usToMs(bufferSizeUs), elapsedSinceLastFeedMs); + } + int result = writeBuffer(buffer, presentationTimeUs); + lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); + return result; + } + + private int writeBuffer(ByteBuffer buffer, long presentationTimeUs) throws WriteException { boolean isNewSourceBuffer = currentSourceBuffer == null; Assertions.checkState(isNewSourceBuffer || currentSourceBuffer == buffer); currentSourceBuffer = buffer; @@ -559,14 +606,14 @@ public final class AudioTrack { if (needsPassthroughWorkarounds()) { // An AC-3 audio track continues to play data written while it is paused. Stop writing so its // buffer empties. See [Internal: b/18899620]. - if (audioTrack.getPlayState() == android.media.AudioTrack.PLAYSTATE_PAUSED) { + if (audioTrack.getPlayState() == PLAYSTATE_PAUSED) { return 0; } // A new AC-3 audio track's playback position continues to increase from the old track's // position for a short time after is has been released. Avoid writing data until the playback // head position actually returns to zero. - if (audioTrack.getPlayState() == android.media.AudioTrack.PLAYSTATE_STOPPED + if (audioTrack.getPlayState() == PLAYSTATE_STOPPED && audioTrackUtil.getPlaybackHeadPosition() != 0) { return 0; } @@ -695,6 +742,24 @@ public final class AudioTrack { audioTrackUtil.setPlaybackParams(playbackParams); } + /** + * Sets the stream type for audio track. If the stream type has changed, {@link #isInitialized()} + * will return {@code false} and the caller must re-{@link #initialize(int)} the audio track + * before writing more data. The caller must not reuse the audio session identifier when + * re-initializing with a new stream type. + * + * @param streamType The {@link C.StreamType} to use for audio output. + * @return Whether the stream type changed. + */ + public boolean setStreamType(@C.StreamType int streamType) { + if (this.streamType == streamType) { + return false; + } + this.streamType = streamType; + reset(); + return true; + } + /** * Sets the playback volume. * @@ -744,7 +809,7 @@ public final class AudioTrack { latencyUs = 0; resetSyncParams(); int playState = audioTrack.getPlayState(); - if (playState == android.media.AudioTrack.PLAYSTATE_PLAYING) { + if (playState == PLAYSTATE_PLAYING) { audioTrack.pause(); } // AudioTrack.release can take some time, so we call it on a background thread. @@ -893,7 +958,7 @@ public final class AudioTrack { */ private void checkAudioTrackInitialized() throws InitializationException { int state = audioTrack.getState(); - if (state == android.media.AudioTrack.STATE_INITIALIZED) { + if (state == STATE_INITIALIZED) { return; } // The track is not successfully initialized. Release and null the track. @@ -951,7 +1016,7 @@ public final class AudioTrack { */ private boolean overrideHasPendingData() { return needsPassthroughWorkarounds() - && audioTrack.getPlayState() == android.media.AudioTrack.PLAYSTATE_PAUSED + && audioTrack.getPlayState() == PLAYSTATE_PAUSED && audioTrack.getPlaybackHeadPosition() == 0; } @@ -981,6 +1046,9 @@ public final class AudioTrack { case C.ENCODING_PCM_32BIT: resampledSize = size / 2; break; + case C.ENCODING_PCM_16BIT: + case C.ENCODING_INVALID: + case Format.NO_VALUE: default: // Never happens. throw new IllegalStateException(); @@ -1016,6 +1084,9 @@ public final class AudioTrack { resampledBuffer.put(buffer.get(i + 3)); } break; + case C.ENCODING_PCM_16BIT: + case C.ENCODING_INVALID: + case Format.NO_VALUE: default: // Never happens. throw new IllegalStateException(); @@ -1056,7 +1127,7 @@ public final class AudioTrack { @TargetApi(21) private static int writeNonBlockingV21( android.media.AudioTrack audioTrack, ByteBuffer buffer, int size) { - return audioTrack.write(buffer, size, android.media.AudioTrack.WRITE_NON_BLOCKING); + return audioTrack.write(buffer, size, WRITE_NON_BLOCKING); } @TargetApi(21) @@ -1149,7 +1220,7 @@ public final class AudioTrack { } int state = audioTrack.getPlayState(); - if (state == android.media.AudioTrack.PLAYSTATE_STOPPED) { + if (state == PLAYSTATE_STOPPED) { // The audio track hasn't been started. return 0; } @@ -1159,7 +1230,7 @@ public final class AudioTrack { // Work around an issue with passthrough/direct AudioTracks on platform API versions 21/22 // where the playback head position jumps back to zero on paused passthrough/direct audio // tracks. See [Internal: b/19187573]. - if (state == android.media.AudioTrack.PLAYSTATE_PAUSED && rawPlaybackHeadPosition == 0) { + if (state == PLAYSTATE_PAUSED && rawPlaybackHeadPosition == 0) { passthroughWorkaroundPauseOffset = lastRawPlaybackHeadPosition; } rawPlaybackHeadPosition += passthroughWorkaroundPauseOffset; diff --git a/library/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 66dd010a6f..648bfd5762 100644 --- a/library/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -16,14 +16,12 @@ package com.google.android.exoplayer2.audio; import android.annotation.TargetApi; -import android.media.AudioManager; import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; import android.media.PlaybackParams; import android.media.audiofx.Virtualizer; import android.os.Handler; -import android.os.SystemClock; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; @@ -43,7 +41,8 @@ import java.nio.ByteBuffer; * Decodes and renders audio using {@link MediaCodec} and {@link AudioTrack}. */ @TargetApi(16) -public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock { +public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock, + AudioTrack.Listener { private final EventDispatcher eventDispatcher; private final AudioTrack audioTrack; @@ -55,9 +54,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private long currentPositionUs; private boolean allowPositionDiscontinuity; - private boolean audioTrackHasData; - private long lastFeedElapsedRealtimeMs; - /** * @param mediaCodecSelector A decoder selector. */ @@ -76,7 +72,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media * has obtained the keys necessary to decrypt encrypted regions of the media. */ public MediaCodecAudioRenderer(MediaCodecSelector mediaCodecSelector, - DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys) { + DrmSessionManager drmSessionManager, + boolean playClearSamplesWithoutKeys) { this(mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, null, null); } @@ -109,7 +106,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media boolean playClearSamplesWithoutKeys, Handler eventHandler, AudioRendererEventListener eventListener) { this(mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, - eventListener, null, AudioManager.STREAM_MUSIC); + eventListener, null); } /** @@ -126,16 +123,14 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioCapabilities The audio capabilities for playback on this device. May be null if the * default capabilities (no encoded audio passthrough support) should be assumed. - * @param streamType The type of audio stream for the {@link AudioTrack}. */ public MediaCodecAudioRenderer(MediaCodecSelector mediaCodecSelector, DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, Handler eventHandler, - AudioRendererEventListener eventListener, AudioCapabilities audioCapabilities, - int streamType) { + AudioRendererEventListener eventListener, AudioCapabilities audioCapabilities) { super(C.TRACK_TYPE_AUDIO, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys); audioSessionId = AudioTrack.SESSION_ID_NOT_SET; - audioTrack = new AudioTrack(audioCapabilities, streamType); + audioTrack = new AudioTrack(audioCapabilities, this); eventDispatcher = new EventDispatcher(eventHandler, eventListener); } @@ -149,7 +144,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media if (allowPassthrough(mimeType) && mediaCodecSelector.getPassthroughDecoderInfo() != null) { return ADAPTIVE_NOT_SEAMLESS | FORMAT_HANDLED; } - MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, false); + MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, false, false); if (decoderInfo == null) { return FORMAT_UNSUPPORTED_SUBTYPE; } @@ -340,29 +335,17 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } else { audioTrack.initialize(audioSessionId); } - audioTrackHasData = false; } catch (AudioTrack.InitializationException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } if (getState() == STATE_STARTED) { audioTrack.play(); } - } else { - // Check for AudioTrack underrun. - boolean audioTrackHadData = audioTrackHasData; - audioTrackHasData = audioTrack.hasPendingData(); - if (audioTrackHadData && !audioTrackHasData && getState() == STATE_STARTED) { - long elapsedSinceLastFeedMs = SystemClock.elapsedRealtime() - lastFeedElapsedRealtimeMs; - long bufferSizeMs = C.usToMs(audioTrack.getBufferSizeUs()); - eventDispatcher.audioTrackUnderrun(audioTrack.getBufferSize(), bufferSizeMs, - elapsedSinceLastFeedMs); - } } int handleBufferResult; try { handleBufferResult = audioTrack.handleBuffer(buffer, bufferPresentationTimeUs); - lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); } catch (AudioTrack.WriteException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } @@ -401,10 +384,23 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case C.MSG_SET_PLAYBACK_PARAMS: audioTrack.setPlaybackParams((PlaybackParams) message); break; + case C.MSG_SET_STREAM_TYPE: + @C.StreamType int streamType = (Integer) message; + if (audioTrack.setStreamType(streamType)) { + audioSessionId = AudioTrack.SESSION_ID_NOT_SET; + } + break; default: super.handleMessage(messageType, message); break; } } + // AudioTrack.Listener implementation. + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/library/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java index 6f15945d9e..572f7b54c1 100644 --- a/library/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -15,10 +15,11 @@ */ package com.google.android.exoplayer2.audio; -import android.media.AudioManager; import android.media.PlaybackParams; import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; +import android.support.annotation.IntDef; import com.google.android.exoplayer2.BaseRenderer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -29,16 +30,48 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TraceUtil; +import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; /** * Decodes and renders audio using a {@link SimpleDecoder}. */ -public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements MediaClock { +public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements MediaClock, + AudioTrack.Listener { + + @Retention(RetentionPolicy.SOURCE) + @IntDef({REINITIALIZATION_STATE_NONE, REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM, + REINITIALIZATION_STATE_WAIT_END_OF_STREAM}) + private @interface ReinitializationState {} + /** + * The decoder does not need to be re-initialized. + */ + private static final int REINITIALIZATION_STATE_NONE = 0; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, but we + * haven't yet signaled an end of stream to the existing decoder. We need to do so in order to + * ensure that it outputs any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM = 1; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, and we've + * signaled an end of stream to the existing decoder. We're waiting for the decoder to output an + * end of stream signal to indicate that it has output any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_WAIT_END_OF_STREAM = 2; + + private final boolean playClearSamplesWithoutKeys; private final EventDispatcher eventDispatcher; + private final AudioTrack audioTrack; + private final DrmSessionManager drmSessionManager; private final FormatHolder formatHolder; private DecoderCounters decoderCounters; @@ -47,18 +80,22 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements ? extends AudioDecoderException> decoder; private DecoderInputBuffer inputBuffer; private SimpleOutputBuffer outputBuffer; + private DrmSession drmSession; + private DrmSession pendingDrmSession; + + @ReinitializationState + private int decoderReinitializationState; + private boolean decoderReceivedBuffers; + private boolean audioTrackNeedsConfigure; private long currentPositionUs; private boolean allowPositionDiscontinuity; private boolean inputStreamEnded; private boolean outputStreamEnded; + private boolean waitingForKeys; - private final AudioTrack audioTrack; private int audioSessionId; - private boolean audioTrackHasData; - private long lastFeedElapsedRealtimeMs; - public SimpleDecoderAudioRenderer() { this(null, null); } @@ -70,7 +107,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements */ public SimpleDecoderAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener) { - this (eventHandler, eventListener, null, AudioManager.STREAM_MUSIC); + this(eventHandler, eventListener, null); } /** @@ -79,16 +116,38 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioCapabilities The audio capabilities for playback on this device. May be null if the * default capabilities (no encoded audio passthrough support) should be assumed. - * @param streamType The type of audio stream for the {@link AudioTrack}. + */ + public SimpleDecoderAudioRenderer(Handler eventHandler, + AudioRendererEventListener eventListener, AudioCapabilities audioCapabilities) { + this(eventHandler, eventListener, audioCapabilities, null, false); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. */ public SimpleDecoderAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, AudioCapabilities audioCapabilities, - int streamType) { + DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys) { super(C.TRACK_TYPE_AUDIO); eventDispatcher = new EventDispatcher(eventHandler, eventListener); - audioSessionId = AudioTrack.SESSION_ID_NOT_SET; - audioTrack = new AudioTrack(audioCapabilities, streamType); + audioTrack = new AudioTrack(audioCapabilities, this); + this.drmSessionManager = drmSessionManager; formatHolder = new FormatHolder(); + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + audioSessionId = AudioTrack.SESSION_ID_NOT_SET; + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + audioTrackNeedsConfigure = true; } @Override @@ -109,43 +168,35 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } // If we don't have a decoder yet, we need to instantiate one. - if (decoder == null) { + maybeInitDecoder(); + + if (decoder != null) { try { - long codecInitializingTimestamp = SystemClock.elapsedRealtime(); - TraceUtil.beginSection("createAudioDecoder"); - decoder = createDecoder(inputFormat); + // Rendering loop. + TraceUtil.beginSection("drainAndFeed"); + while (drainOutputBuffer()) {} + while (feedInputBuffer()) {} TraceUtil.endSection(); - long codecInitializedTimestamp = SystemClock.elapsedRealtime(); - eventDispatcher.decoderInitialized(decoder.getName(), codecInitializedTimestamp, - codecInitializedTimestamp - codecInitializingTimestamp); - decoderCounters.decoderInitCount++; - } catch (AudioDecoderException e) { + } catch (AudioTrack.InitializationException | AudioTrack.WriteException + | AudioDecoderException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } + decoderCounters.ensureUpdated(); } - - // Rendering loop. - try { - TraceUtil.beginSection("drainAndFeed"); - while (drainOutputBuffer()) {} - while (feedInputBuffer()) {} - TraceUtil.endSection(); - } catch (AudioTrack.InitializationException | AudioTrack.WriteException - | AudioDecoderException e) { - throw ExoPlaybackException.createForRenderer(e, getIndex()); - } - decoderCounters.ensureUpdated(); } /** * Creates a decoder for the given format. * * @param format The format for which a decoder is required. + * @param mediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted content. + * Maybe null and can be ignored if decoder does not handle encrypted content. * @return The decoder. * @throws AudioDecoderException If an error occurred creating a suitable decoder. */ protected abstract SimpleDecoder createDecoder(Format format) throws AudioDecoderException; + ? extends AudioDecoderException> createDecoder(Format format, ExoMediaCrypto mediaCrypto) + throws AudioDecoderException; /** * Returns the format of audio buffers output by the decoder. Will not be called until the first @@ -160,12 +211,8 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements null, null, 0, null); } - private boolean drainOutputBuffer() throws AudioDecoderException, + private boolean drainOutputBuffer() throws ExoPlaybackException, AudioDecoderException, AudioTrack.InitializationException, AudioTrack.WriteException { - if (outputStreamEnded) { - return false; - } - if (outputBuffer == null) { outputBuffer = decoder.dequeueOutputBuffer(); if (outputBuffer == null) { @@ -175,17 +222,29 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } if (outputBuffer.isEndOfStream()) { - outputStreamEnded = true; - audioTrack.handleEndOfStream(); - outputBuffer.release(); - outputBuffer = null; + if (decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { + // We're waiting to re-initialize the decoder, and have now processed all final buffers. + releaseDecoder(); + maybeInitDecoder(); + // The audio track may need to be recreated once the new output format is known. + audioTrackNeedsConfigure = true; + } else { + outputBuffer.release(); + outputBuffer = null; + outputStreamEnded = true; + audioTrack.handleEndOfStream(); + } return false; } - if (!audioTrack.isInitialized()) { + if (audioTrackNeedsConfigure) { Format outputFormat = getOutputFormat(); audioTrack.configure(outputFormat.sampleMimeType, outputFormat.channelCount, outputFormat.sampleRate, outputFormat.pcmEncoding, 0); + audioTrackNeedsConfigure = false; + } + + if (!audioTrack.isInitialized()) { if (audioSessionId == AudioTrack.SESSION_ID_NOT_SET) { audioSessionId = audioTrack.initialize(AudioTrack.SESSION_ID_NOT_SET); eventDispatcher.audioSessionId(audioSessionId); @@ -193,24 +252,12 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } else { audioTrack.initialize(audioSessionId); } - audioTrackHasData = false; if (getState() == STATE_STARTED) { audioTrack.play(); } - } else { - // Check for AudioTrack underrun. - boolean audioTrackHadData = audioTrackHasData; - audioTrackHasData = audioTrack.hasPendingData(); - if (audioTrackHadData && !audioTrackHasData && getState() == STATE_STARTED) { - long elapsedSinceLastFeedMs = SystemClock.elapsedRealtime() - lastFeedElapsedRealtimeMs; - long bufferSizeMs = C.usToMs(audioTrack.getBufferSizeUs()); - eventDispatcher.audioTrackUnderrun(audioTrack.getBufferSize(), bufferSizeMs, - elapsedSinceLastFeedMs); - } } int handleBufferResult = audioTrack.handleBuffer(outputBuffer.data, outputBuffer.timeUs); - lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); // If we are out of sync, allow currentPositionUs to jump backwards. if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) { @@ -228,8 +275,10 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return false; } - private boolean feedInputBuffer() throws AudioDecoderException { - if (inputStreamEnded) { + private boolean feedInputBuffer() throws AudioDecoderException, ExoPlaybackException { + if (decoder == null || decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM + || inputStreamEnded) { + // We need to reinitialize the decoder or the input stream has ended. return false; } @@ -240,7 +289,22 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } } - int result = readSource(formatHolder, inputBuffer); + if (decoderReinitializationState == REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM) { + inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + decoderReinitializationState = REINITIALIZATION_STATE_WAIT_END_OF_STREAM; + return false; + } + + int result; + if (waitingForKeys) { + // We've already read an encrypted sample into buffer, and are waiting for keys. + result = C.RESULT_BUFFER_READ; + } else { + result = readSource(formatHolder, inputBuffer); + } + if (result == C.RESULT_NOTHING_READ) { return false; } @@ -254,20 +318,45 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements inputBuffer = null; return false; } + boolean bufferEncrypted = inputBuffer.isEncrypted(); + waitingForKeys = shouldWaitForKeys(bufferEncrypted); + if (waitingForKeys) { + return false; + } inputBuffer.flip(); decoder.queueInputBuffer(inputBuffer); + decoderReceivedBuffers = true; decoderCounters.inputBufferCount++; inputBuffer = null; return true; } - private void flushDecoder() { - inputBuffer = null; - if (outputBuffer != null) { - outputBuffer.release(); - outputBuffer = null; + private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { + if (drmSession == null) { + return false; + } + @DrmSession.State int drmSessionState = drmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + } + return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS + && (bufferEncrypted || !playClearSamplesWithoutKeys); + } + + private void flushDecoder() throws ExoPlaybackException { + waitingForKeys = false; + if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { + releaseDecoder(); + maybeInitDecoder(); + } else { + inputBuffer = null; + if (outputBuffer != null) { + outputBuffer.release(); + outputBuffer = null; + } + decoder.flush(); + decoderReceivedBuffers = false; } - decoder.flush(); } @Override @@ -278,7 +367,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements @Override public boolean isReady() { return audioTrack.hasPendingData() - || (inputFormat != null && (isSourceReady() || outputBuffer != null)); + || (inputFormat != null && !waitingForKeys && (isSourceReady() || outputBuffer != null)); } @Override @@ -312,7 +401,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } @Override - protected void onPositionReset(long positionUs, boolean joining) { + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { audioTrack.reset(); currentPositionUs = positionUs; allowPositionDiscontinuity = true; @@ -335,24 +424,82 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements @Override protected void onDisabled() { - inputBuffer = null; - outputBuffer = null; inputFormat = null; audioSessionId = AudioTrack.SESSION_ID_NOT_SET; + audioTrackNeedsConfigure = true; + waitingForKeys = false; try { - if (decoder != null) { - decoder.release(); - decoder = null; - decoderCounters.decoderReleaseCount++; - } + releaseDecoder(); audioTrack.release(); } finally { - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); + try { + if (drmSession != null) { + drmSessionManager.releaseSession(drmSession); + } + } finally { + try { + if (pendingDrmSession != null && pendingDrmSession != drmSession) { + drmSessionManager.releaseSession(pendingDrmSession); + } + } finally { + drmSession = null; + pendingDrmSession = null; + decoderCounters.ensureUpdated(); + eventDispatcher.disabled(decoderCounters); + } + } } } - private boolean readFormat() { + private void maybeInitDecoder() throws ExoPlaybackException { + if (decoder != null) { + return; + } + + drmSession = pendingDrmSession; + ExoMediaCrypto mediaCrypto = null; + if (drmSession != null) { + @DrmSession.State int drmSessionState = drmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + } else if (drmSessionState == DrmSession.STATE_OPENED + || drmSessionState == DrmSession.STATE_OPENED_WITH_KEYS) { + mediaCrypto = drmSession.getMediaCrypto(); + } else { + // The drm session isn't open yet. + return; + } + } + + try { + long codecInitializingTimestamp = SystemClock.elapsedRealtime(); + TraceUtil.beginSection("createAudioDecoder"); + decoder = createDecoder(inputFormat, mediaCrypto); + TraceUtil.endSection(); + long codecInitializedTimestamp = SystemClock.elapsedRealtime(); + eventDispatcher.decoderInitialized(decoder.getName(), codecInitializedTimestamp, + codecInitializedTimestamp - codecInitializingTimestamp); + decoderCounters.decoderInitCount++; + } catch (AudioDecoderException e) { + throw ExoPlaybackException.createForRenderer(e, getIndex()); + } + } + + private void releaseDecoder() { + if (decoder == null) { + return; + } + + inputBuffer = null; + outputBuffer = null; + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + decoderReceivedBuffers = false; + } + + private boolean readFormat() throws ExoPlaybackException { int result = readSource(formatHolder, null); if (result == C.RESULT_FORMAT_READ) { onInputFormatChanged(formatHolder.format); @@ -361,8 +508,37 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return false; } - private void onInputFormatChanged(Format newFormat) { + private void onInputFormatChanged(Format newFormat) throws ExoPlaybackException { + Format oldFormat = inputFormat; inputFormat = newFormat; + + boolean drmInitDataChanged = !Util.areEqual(inputFormat.drmInitData, oldFormat == null ? null + : oldFormat.drmInitData); + if (drmInitDataChanged) { + if (inputFormat.drmInitData != null) { + if (drmSessionManager == null) { + throw ExoPlaybackException.createForRenderer( + new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); + } + pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), + inputFormat.drmInitData); + if (pendingDrmSession == drmSession) { + drmSessionManager.releaseSession(pendingDrmSession); + } + } else { + pendingDrmSession = null; + } + } + + if (decoderReceivedBuffers) { + // Signal end of stream and wait for any final output buffers before re-initialization. + decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; + } else { + // There aren't any final output buffers, so release the decoder immediately. + releaseDecoder(); + maybeInitDecoder(); + } + eventDispatcher.inputFormatChanged(newFormat); } @@ -375,10 +551,23 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements case C.MSG_SET_PLAYBACK_PARAMS: audioTrack.setPlaybackParams((PlaybackParams) message); break; + case C.MSG_SET_STREAM_TYPE: + @C.StreamType int streamType = (Integer) message; + if (audioTrack.setStreamType(streamType)) { + audioSessionId = AudioTrack.SESSION_ID_NOT_SET; + } + break; default: super.handleMessage(messageType, message); break; } } + // AudioTrack.Listener implementation. + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/DecryptionException.java b/library/src/main/java/com/google/android/exoplayer2/drm/DecryptionException.java new file mode 100644 index 0000000000..6916b972b2 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/drm/DecryptionException.java @@ -0,0 +1,20 @@ +package com.google.android.exoplayer2.drm; + +/** + * An exception when doing drm decryption using the In-App Drm + */ +public class DecryptionException extends Exception { + private final int errorCode; + + public DecryptionException(int errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * Get error code + */ + public int getErrorCode() { + return errorCode; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java b/library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java index f3c6595736..4e4845c70b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java +++ b/library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java @@ -447,15 +447,15 @@ public class StreamingDrmSessionManager implements Drm switch (msg.what) { case MediaDrm.EVENT_KEY_REQUIRED: postKeyRequest(); - return; + break; case MediaDrm.EVENT_KEY_EXPIRED: state = STATE_OPENED; onError(new KeysExpiredException()); - return; + break; case MediaDrm.EVENT_PROVISION_REQUIRED: state = STATE_OPENED; postProvisionRequest(); - return; + break; } } @@ -483,10 +483,10 @@ public class StreamingDrmSessionManager implements Drm switch (msg.what) { case MSG_PROVISION: onProvisionResponse(msg.obj); - return; + break; case MSG_KEYS: onKeyResponse(msg.obj); - return; + break; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java index 4120110afb..38b0325cba 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java @@ -93,9 +93,10 @@ public interface Extractor { * position} in the stream. Valid random access positions are the start of the stream and * positions that can be obtained from any {@link SeekMap} passed to the {@link ExtractorOutput}. * - * @param position The seek position. + * @param position The byte offset in the stream from which data will be provided. + * @param timeUs The seek time in microseconds. */ - void seek(long position); + void seek(long position, long timeUs); /** * Releases all kept resources. diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java index 6eb9bc50de..7e2a1b4a23 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.extractor; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -65,6 +67,25 @@ public final class GaplessInfoHolder { return false; } + /** + * Populates the holder with data parsed from ID3 {@link Metadata}. + * + * @param metadata The metadata from which to parse the gapless information. + * @return Whether the holder was populated. + */ + public boolean setFromMetadata(Metadata metadata) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + if (setFromComment(commentFrame.description, commentFrame.text)) { + return true; + } + } + } + return false; + } + /** * Populates the holder with data parsed from a gapless playback comment (stored in an ID3 header * or MPEG 4 user data), if valid and non-zero. @@ -73,7 +94,7 @@ public final class GaplessInfoHolder { * @param data The comment's payload data. * @return Whether the holder was populated. */ - public boolean setFromComment(String name, String data) { + private boolean setFromComment(String name, String data) { if (!GAPLESS_COMMENT_ID.equals(name)) { return false; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java index b48c4881d9..3ee87b47ea 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java @@ -29,21 +29,17 @@ import java.util.Collections; */ /* package */ final class AudioTagPayloadReader extends TagPayloadReader { - // Audio format + private static final int AUDIO_FORMAT_ALAW = 7; + private static final int AUDIO_FORMAT_ULAW = 8; private static final int AUDIO_FORMAT_AAC = 10; - // AAC PACKET TYPE private static final int AAC_PACKET_TYPE_SEQUENCE_HEADER = 0; private static final int AAC_PACKET_TYPE_AAC_RAW = 1; - // SAMPLING RATES - private static final int[] AUDIO_SAMPLING_RATE_TABLE = new int[] { - 5500, 11000, 22000, 44000 - }; - // State variables private boolean hasParsedAudioDataHeader; private boolean hasOutputFormat; + private int audioFormat; public AudioTagPayloadReader(TrackOutput output) { super(output); @@ -58,13 +54,17 @@ import java.util.Collections; protected boolean parseHeader(ParsableByteArray data) throws UnsupportedFormatException { if (!hasParsedAudioDataHeader) { int header = data.readUnsignedByte(); - int audioFormat = (header >> 4) & 0x0F; - int sampleRateIndex = (header >> 2) & 0x03; - if (sampleRateIndex < 0 || sampleRateIndex >= AUDIO_SAMPLING_RATE_TABLE.length) { - throw new UnsupportedFormatException("Invalid sample rate index: " + sampleRateIndex); - } - // TODO: Add support for MP3 and PCM. - if (audioFormat != AUDIO_FORMAT_AAC) { + audioFormat = (header >> 4) & 0x0F; + // TODO: Add support for MP3. + if (audioFormat == AUDIO_FORMAT_ALAW || audioFormat == AUDIO_FORMAT_ULAW) { + String type = audioFormat == AUDIO_FORMAT_ALAW ? MimeTypes.AUDIO_ALAW + : MimeTypes.AUDIO_ULAW; + int pcmEncoding = (header & 0x01) == 1 ? C.ENCODING_PCM_16BIT : C.ENCODING_PCM_8BIT; + Format format = Format.createAudioSampleFormat(null, type, null, Format.NO_VALUE, + Format.NO_VALUE, 1, 8000, pcmEncoding, null, null, 0, null); + output.format(format); + hasOutputFormat = true; + } else if (audioFormat != AUDIO_FORMAT_AAC) { throw new UnsupportedFormatException("Audio format not supported: " + audioFormat); } hasParsedAudioDataHeader = true; @@ -78,22 +78,21 @@ import java.util.Collections; @Override protected void parsePayload(ParsableByteArray data, long timeUs) { int packetType = data.readUnsignedByte(); - // Parse sequence header just in case it was not done before. if (packetType == AAC_PACKET_TYPE_SEQUENCE_HEADER && !hasOutputFormat) { - byte[] audioSpecifiConfig = new byte[data.bytesLeft()]; - data.readBytes(audioSpecifiConfig, 0, audioSpecifiConfig.length); + // Parse the sequence header. + byte[] audioSpecificConfig = new byte[data.bytesLeft()]; + data.readBytes(audioSpecificConfig, 0, audioSpecificConfig.length); Pair audioParams = CodecSpecificDataUtil.parseAacAudioSpecificConfig( - audioSpecifiConfig); + audioSpecificConfig); Format format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_AAC, null, Format.NO_VALUE, Format.NO_VALUE, audioParams.second, audioParams.first, - Collections.singletonList(audioSpecifiConfig), null, 0, null); + Collections.singletonList(audioSpecificConfig), null, 0, null); output.format(format); hasOutputFormat = true; - } else if (packetType == AAC_PACKET_TYPE_AAC_RAW) { - // Sample audio AAC frames - int bytesToWrite = data.bytesLeft(); - output.sampleData(data, bytesToWrite); - output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, bytesToWrite, 0, null); + } else if (audioFormat != AUDIO_FORMAT_AAC || packetType == AAC_PACKET_TYPE_AAC_RAW) { + int sampleSize = data.bytesLeft(); + output.sampleData(data, sampleSize); + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java index 9c3721d8fe..5b396749ac 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java @@ -126,7 +126,7 @@ public final class FlvExtractor implements Extractor, SeekMap { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { parserState = STATE_READING_FLV_HEADER; bytesToNextTagHeader = 0; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index bc2d891dab..ccf78e6bc6 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.MpegAudioHeader; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; @@ -84,6 +85,7 @@ public final class MatroskaExtractor implements Extractor { private static final String CODEC_ID_VORBIS = "A_VORBIS"; private static final String CODEC_ID_OPUS = "A_OPUS"; private static final String CODEC_ID_AAC = "A_AAC"; + private static final String CODEC_ID_MP2 = "A_MPEG/L2"; private static final String CODEC_ID_MP3 = "A_MPEG/L3"; private static final String CODEC_ID_AC3 = "A_AC3"; private static final String CODEC_ID_E_AC3 = "A_EAC3"; @@ -100,7 +102,6 @@ public final class MatroskaExtractor implements Extractor { private static final int VORBIS_MAX_INPUT_SIZE = 8192; private static final int OPUS_MAX_INPUT_SIZE = 5760; - private static final int MP3_MAX_INPUT_SIZE = 4096; private static final int ENCRYPTION_IV_SIZE = 8; private static final int TRACK_TYPE_AUDIO = 2; @@ -317,7 +318,7 @@ public final class MatroskaExtractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { clusterTimecodeUs = C.TIME_UNSET; blockState = BLOCK_STATE_START; reader.reset(); @@ -431,18 +432,18 @@ public final class MatroskaExtractor implements Extractor { } segmentContentPosition = contentPosition; segmentContentSize = contentSize; - return; + break; case ID_SEEK: seekEntryId = UNSET_ENTRY_ID; seekEntryPosition = C.POSITION_UNSET; - return; + break; case ID_CUES: cueTimesUs = new LongArray(); cueClusterPositions = new LongArray(); - return; + break; case ID_CUE_POINT: seenClusterPositionForCurrentCuePoint = false; - return; + break; case ID_CLUSTER: if (!sentSeekMap) { // We need to build cues before parsing the cluster. @@ -456,21 +457,21 @@ public final class MatroskaExtractor implements Extractor { sentSeekMap = true; } } - return; + break; case ID_BLOCK_GROUP: sampleSeenReferenceBlock = false; - return; + break; case ID_CONTENT_ENCODING: // TODO: check and fail if more than one content encoding is present. - return; + break; case ID_CONTENT_ENCRYPTION: currentTrack.hasContentEncryption = true; - return; + break; case ID_TRACK_ENTRY: currentTrack = new Track(); - return; + break; default: - return; + break; } } @@ -484,7 +485,7 @@ public final class MatroskaExtractor implements Extractor { if (durationTimecode != C.TIME_UNSET) { durationUs = scaleTimecodeToUs(durationTimecode); } - return; + break; case ID_SEEK: if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.POSITION_UNSET) { throw new ParserException("Mandatory element SeekID or SeekPosition not found"); @@ -492,7 +493,7 @@ public final class MatroskaExtractor implements Extractor { if (seekEntryId == ID_CUES) { cuesContentPosition = seekEntryPosition; } - return; + break; case ID_CUES: if (!sentSeekMap) { extractorOutput.seekMap(buildSeekMap()); @@ -500,7 +501,7 @@ public final class MatroskaExtractor implements Extractor { } else { // We have already built the cues. Ignore. } - return; + break; case ID_BLOCK_GROUP: if (blockState != BLOCK_STATE_DATA) { // We've skipped this block (due to incompatible track number). @@ -512,7 +513,7 @@ public final class MatroskaExtractor implements Extractor { } commitSampleToOutput(tracks.get(blockTrackNumber), blockTimeUs); blockState = BLOCK_STATE_START; - return; + break; case ID_CONTENT_ENCODING: if (currentTrack.hasContentEncryption) { if (currentTrack.encryptionKeyId == null) { @@ -521,12 +522,12 @@ public final class MatroskaExtractor implements Extractor { currentTrack.drmInitData = new DrmInitData( new SchemeData(C.UUID_NIL, MimeTypes.VIDEO_WEBM, currentTrack.encryptionKeyId)); } - return; + break; case ID_CONTENT_ENCODINGS: if (currentTrack.hasContentEncryption && currentTrack.sampleStrippedBytes != null) { throw new ParserException("Combining encryption and compression is not supported"); } - return; + break; case ID_TRACK_ENTRY: if (tracks.get(currentTrack.number) == null && isCodecSupported(currentTrack.codecId)) { currentTrack.initializeOutput(extractorOutput, currentTrack.number); @@ -535,15 +536,15 @@ public final class MatroskaExtractor implements Extractor { // We've seen this track entry before, or the codec is unsupported. Do nothing. } currentTrack = null; - return; + break; case ID_TRACKS: if (tracks.size() == 0) { throw new ParserException("No valid tracks were found"); } extractorOutput.endTracks(); - return; + break; default: - return; + break; } } @@ -554,99 +555,99 @@ public final class MatroskaExtractor implements Extractor { if (value != 1) { throw new ParserException("EBMLReadVersion " + value + " not supported"); } - return; + break; case ID_DOC_TYPE_READ_VERSION: // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. if (value < 1 || value > 2) { throw new ParserException("DocTypeReadVersion " + value + " not supported"); } - return; + break; case ID_SEEK_POSITION: // Seek Position is the relative offset beginning from the Segment. So to get absolute // offset from the beginning of the file, we need to add segmentContentPosition to it. seekEntryPosition = value + segmentContentPosition; - return; + break; case ID_TIMECODE_SCALE: timecodeScale = value; - return; + break; case ID_PIXEL_WIDTH: currentTrack.width = (int) value; - return; + break; case ID_PIXEL_HEIGHT: currentTrack.height = (int) value; - return; + break; case ID_DISPLAY_WIDTH: currentTrack.displayWidth = (int) value; - return; + break; case ID_DISPLAY_HEIGHT: currentTrack.displayHeight = (int) value; - return; + break; case ID_DISPLAY_UNIT: currentTrack.displayUnit = (int) value; - return; + break; case ID_TRACK_NUMBER: currentTrack.number = (int) value; - return; + break; case ID_FLAG_DEFAULT: currentTrack.flagForced = value == 1; - return; + break; case ID_FLAG_FORCED: currentTrack.flagDefault = value == 1; - return; + break; case ID_TRACK_TYPE: currentTrack.type = (int) value; - return; + break; case ID_DEFAULT_DURATION: currentTrack.defaultSampleDurationNs = (int) value; - return; + break; case ID_CODEC_DELAY: currentTrack.codecDelayNs = value; - return; + break; case ID_SEEK_PRE_ROLL: currentTrack.seekPreRollNs = value; - return; + break; case ID_CHANNELS: currentTrack.channelCount = (int) value; - return; + break; case ID_AUDIO_BIT_DEPTH: currentTrack.audioBitDepth = (int) value; - return; + break; case ID_REFERENCE_BLOCK: sampleSeenReferenceBlock = true; - return; + break; case ID_CONTENT_ENCODING_ORDER: // This extractor only supports one ContentEncoding element and hence the order has to be 0. if (value != 0) { throw new ParserException("ContentEncodingOrder " + value + " not supported"); } - return; + break; case ID_CONTENT_ENCODING_SCOPE: // This extractor only supports the scope of all frames. if (value != 1) { throw new ParserException("ContentEncodingScope " + value + " not supported"); } - return; + break; case ID_CONTENT_COMPRESSION_ALGORITHM: // This extractor only supports header stripping. if (value != 3) { throw new ParserException("ContentCompAlgo " + value + " not supported"); } - return; + break; case ID_CONTENT_ENCRYPTION_ALGORITHM: // Only the value 5 (AES) is allowed according to the WebM specification. if (value != 5) { throw new ParserException("ContentEncAlgo " + value + " not supported"); } - return; + break; case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: // Only the value 1 is allowed according to the WebM specification. if (value != 1) { throw new ParserException("AESSettingsCipherMode " + value + " not supported"); } - return; + break; case ID_CUE_TIME: cueTimesUs.add(scaleTimecodeToUs(value)); - return; + break; case ID_CUE_CLUSTER_POSITION: if (!seenClusterPositionForCurrentCuePoint) { // If there's more than one video/audio track, then there could be more than one @@ -655,13 +656,13 @@ public final class MatroskaExtractor implements Extractor { cueClusterPositions.add(value); seenClusterPositionForCurrentCuePoint = true; } - return; + break; case ID_TIME_CODE: clusterTimecodeUs = scaleTimecodeToUs(value); - return; + break; case ID_BLOCK_DURATION: blockDurationUs = scaleTimecodeToUs(value); - return; + break; case ID_STEREO_MODE: int layout = (int) value; switch (layout) { @@ -677,9 +678,9 @@ public final class MatroskaExtractor implements Extractor { default: break; } - return; + break; default: - return; + break; } } @@ -687,12 +688,12 @@ public final class MatroskaExtractor implements Extractor { switch (id) { case ID_DURATION: durationTimecode = (long) value; - return; + break; case ID_SAMPLING_FREQUENCY: currentTrack.sampleRate = (int) value; - return; + break; default: - return; + break; } } @@ -703,15 +704,15 @@ public final class MatroskaExtractor implements Extractor { if (!DOC_TYPE_WEBM.equals(value) && !DOC_TYPE_MATROSKA.equals(value)) { throw new ParserException("DocType " + value + " not supported"); } - return; + break; case ID_CODEC_ID: currentTrack.codecId = value; - return; + break; case ID_LANGUAGE: currentTrack.language = value; - return; + break; default: - return; + break; } } @@ -723,24 +724,24 @@ public final class MatroskaExtractor implements Extractor { input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize); seekEntryIdBytes.setPosition(0); seekEntryId = (int) seekEntryIdBytes.readUnsignedInt(); - return; + break; case ID_CODEC_PRIVATE: currentTrack.codecPrivate = new byte[contentSize]; input.readFully(currentTrack.codecPrivate, 0, contentSize); - return; + break; case ID_PROJECTION_PRIVATE: currentTrack.projectionData = new byte[contentSize]; input.readFully(currentTrack.projectionData, 0, contentSize); - return; + break; case ID_CONTENT_COMPRESSION_SETTINGS: // This extractor only supports header stripping, so the payload is the stripped bytes. currentTrack.sampleStrippedBytes = new byte[contentSize]; input.readFully(currentTrack.sampleStrippedBytes, 0, contentSize); - return; + break; case ID_CONTENT_ENCRYPTION_KEY_ID: currentTrack.encryptionKeyId = new byte[contentSize]; input.readFully(currentTrack.encryptionKeyId, 0, contentSize); - return; + break; case ID_SIMPLE_BLOCK: case ID_BLOCK: // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure @@ -873,7 +874,7 @@ public final class MatroskaExtractor implements Extractor { writeSampleData(input, track, blockLacingSampleSizes[0]); } - return; + break; default: throw new ParserException("Unexpected id: " + id); } @@ -1218,6 +1219,7 @@ public final class MatroskaExtractor implements Extractor { || CODEC_ID_OPUS.equals(codecId) || CODEC_ID_VORBIS.equals(codecId) || CODEC_ID_AAC.equals(codecId) + || CODEC_ID_MP2.equals(codecId) || CODEC_ID_MP3.equals(codecId) || CODEC_ID_AC3.equals(codecId) || CODEC_ID_E_AC3.equals(codecId) @@ -1403,9 +1405,13 @@ public final class MatroskaExtractor implements Extractor { mimeType = MimeTypes.AUDIO_AAC; initializationData = Collections.singletonList(codecPrivate); break; + case CODEC_ID_MP2: + mimeType = MimeTypes.AUDIO_MPEG_L2; + maxInputSize = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; + break; case CODEC_ID_MP3: mimeType = MimeTypes.AUDIO_MPEG; - maxInputSize = MP3_MAX_INPUT_SIZE; + maxInputSize = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; break; case CODEC_ID_AC3: mimeType = MimeTypes.AUDIO_AC3; diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java index 3d3e677881..a3fde6d455 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java @@ -40,7 +40,7 @@ import java.io.IOException; } /** - * @see Extractor#sniff + * @see com.google.android.exoplayer2.extractor.Extractor#sniff(ExtractorInput) */ public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { long inputLength = input.getLength(); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java deleted file mode 100644 index 53f18df844..0000000000 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.extractor.mp3; - -import android.util.Pair; -import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.GaplessInfoHolder; -import com.google.android.exoplayer2.util.ParsableByteArray; -import com.google.android.exoplayer2.util.Util; -import java.io.IOException; -import java.nio.charset.Charset; - -/** - * Utility for parsing ID3 version 2 metadata in MP3 files. - */ -/* package */ final class Id3Util { - - /** - * The maximum valid length for metadata in bytes. - */ - private static final int MAXIMUM_METADATA_SIZE = 3 * 1024 * 1024; - - private static final int ID3_TAG = Util.getIntegerCodeForString("ID3"); - private static final Charset[] CHARSET_BY_ENCODING = new Charset[] {Charset.forName("ISO-8859-1"), - Charset.forName("UTF-16LE"), Charset.forName("UTF-16BE"), Charset.forName("UTF-8")}; - - /** - * Peeks data from the input and parses ID3 metadata. - * - * @param input The {@link ExtractorInput} from which data should be peeked. - * @param out The {@link GaplessInfoHolder} to populate. - * @throws IOException If an error occurred peeking from the input. - * @throws InterruptedException If the thread was interrupted. - */ - public static void parseId3(ExtractorInput input, GaplessInfoHolder out) - throws IOException, InterruptedException { - ParsableByteArray scratch = new ParsableByteArray(10); - int peekedId3Bytes = 0; - while (true) { - input.peekFully(scratch.data, 0, 10); - scratch.setPosition(0); - if (scratch.readUnsignedInt24() != ID3_TAG) { - break; - } - - int majorVersion = scratch.readUnsignedByte(); - int minorVersion = scratch.readUnsignedByte(); - int flags = scratch.readUnsignedByte(); - int length = scratch.readSynchSafeInt(); - if (!out.hasGaplessInfo() && canParseMetadata(majorVersion, minorVersion, flags, length)) { - byte[] frame = new byte[length]; - input.peekFully(frame, 0, length); - parseGaplessInfo(new ParsableByteArray(frame), majorVersion, flags, out); - } else { - input.advancePeekPosition(length); - } - - peekedId3Bytes += 10 + length; - } - input.resetPeekPosition(); - input.advancePeekPosition(peekedId3Bytes); - } - - private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags, - int length) { - return minorVersion != 0xFF && majorVersion >= 2 && majorVersion <= 4 - && length <= MAXIMUM_METADATA_SIZE - && !(majorVersion == 2 && ((flags & 0x3F) != 0 || (flags & 0x40) != 0)) - && !(majorVersion == 3 && (flags & 0x1F) != 0) - && !(majorVersion == 4 && (flags & 0x0F) != 0); - } - - private static void parseGaplessInfo(ParsableByteArray frame, int version, int flags, - GaplessInfoHolder out) { - unescape(frame, version, flags); - - // Skip any extended header. - frame.setPosition(0); - if (version == 3 && (flags & 0x40) != 0) { - if (frame.bytesLeft() < 4) { - return; - } - int extendedHeaderSize = frame.readUnsignedIntToInt(); - if (extendedHeaderSize > frame.bytesLeft()) { - return; - } - int paddingSize; - if (extendedHeaderSize >= 6) { - frame.skipBytes(2); // extended flags - paddingSize = frame.readUnsignedIntToInt(); - frame.setPosition(4); - frame.setLimit(frame.limit() - paddingSize); - if (frame.bytesLeft() < extendedHeaderSize) { - return; - } - } - frame.skipBytes(extendedHeaderSize); - } else if (version == 4 && (flags & 0x40) != 0) { - if (frame.bytesLeft() < 4) { - return; - } - int extendedHeaderSize = frame.readSynchSafeInt(); - if (extendedHeaderSize < 6 || extendedHeaderSize > frame.bytesLeft() + 4) { - return; - } - frame.setPosition(extendedHeaderSize); - } - - // Extract gapless playback metadata stored in comments. - Pair comment; - while ((comment = findNextComment(version, frame)) != null) { - if (comment.first.length() > 3) { - if (out.setFromComment(comment.first.substring(3), comment.second)) { - break; - } - } - } - } - - private static Pair findNextComment(int majorVersion, ParsableByteArray data) { - int frameSize; - while (true) { - if (majorVersion == 2) { - if (data.bytesLeft() < 6) { - return null; - } - String id = data.readString(3, Charset.forName("US-ASCII")); - if (id.equals("\0\0\0")) { - return null; - } - frameSize = data.readUnsignedInt24(); - if (frameSize == 0 || frameSize > data.bytesLeft()) { - return null; - } - if (id.equals("COM")) { - break; - } - } else /* major == 3 || major == 4 */ { - if (data.bytesLeft() < 10) { - return null; - } - String id = data.readString(4, Charset.forName("US-ASCII")); - if (id.equals("\0\0\0\0")) { - return null; - } - frameSize = majorVersion == 4 ? data.readSynchSafeInt() : data.readUnsignedIntToInt(); - if (frameSize == 0 || frameSize > data.bytesLeft() - 2) { - return null; - } - int flags = data.readUnsignedShort(); - boolean compressedOrEncrypted = (majorVersion == 4 && (flags & 0x0C) != 0) - || (majorVersion == 3 && (flags & 0xC0) != 0); - if (!compressedOrEncrypted && id.equals("COMM")) { - break; - } - } - data.skipBytes(frameSize); - } - - // The comment tag is at the reading position in data. - int encoding = data.readUnsignedByte(); - if (encoding < 0 || encoding >= CHARSET_BY_ENCODING.length) { - return null; - } - Charset charset = CHARSET_BY_ENCODING[encoding]; - String[] commentFields = data.readString(frameSize - 1, charset).split("\0"); - return commentFields.length == 2 ? Pair.create(commentFields[0], commentFields[1]) : null; - } - - private static boolean unescape(ParsableByteArray frame, int version, int flags) { - if (version != 4) { - if ((flags & 0x80) != 0) { - // Remove unsynchronization on ID3 version < 2.4.0. - byte[] bytes = frame.data; - int newLength = bytes.length; - for (int i = 0; i + 1 < newLength; i++) { - if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { - System.arraycopy(bytes, i + 2, bytes, i + 1, newLength - i - 2); - newLength--; - } - } - frame.setLimit(newLength); - } - } else { - // Remove unsynchronization on ID3 version 2.4.0. - if (canUnescapeVersion4(frame, false)) { - unescapeVersion4(frame, false); - } else if (canUnescapeVersion4(frame, true)) { - unescapeVersion4(frame, true); - } else { - return false; - } - } - return true; - } - - private static boolean canUnescapeVersion4(ParsableByteArray frame, - boolean unsignedIntDataSizeHack) { - frame.setPosition(0); - while (frame.bytesLeft() >= 10) { - if (frame.readInt() == 0) { - return true; - } - long dataSize = frame.readUnsignedInt(); - if (!unsignedIntDataSizeHack) { - // Parse the data size as a syncsafe integer. - if ((dataSize & 0x808080L) != 0) { - return false; - } - dataSize = (dataSize & 0x7F) | (((dataSize >> 8) & 0x7F) << 7) - | (((dataSize >> 16) & 0x7F) << 14) | (((dataSize >> 24) & 0x7F) << 21); - } - if (dataSize > frame.bytesLeft() - 2) { - return false; - } - int flags = frame.readUnsignedShort(); - if ((flags & 1) != 0) { - if (frame.bytesLeft() < 4) { - return false; - } - } - frame.skipBytes((int) dataSize); - } - return true; - } - - private static void unescapeVersion4(ParsableByteArray frame, boolean unsignedIntDataSizeHack) { - frame.setPosition(0); - byte[] bytes = frame.data; - while (frame.bytesLeft() >= 10) { - if (frame.readInt() == 0) { - return; - } - int dataSize = - unsignedIntDataSizeHack ? frame.readUnsignedIntToInt() : frame.readSynchSafeInt(); - int flags = frame.readUnsignedShort(); - int previousFlags = flags; - if ((flags & 1) != 0) { - // Strip data length indicator. - int offset = frame.getPosition(); - System.arraycopy(bytes, offset + 4, bytes, offset, frame.bytesLeft() - 4); - dataSize -= 4; - flags &= ~1; - frame.setLimit(frame.limit() - 4); - } - if ((flags & 2) != 0) { - // Unescape 0xFF00 to 0xFF in the next dataSize bytes. - int readOffset = frame.getPosition() + 1; - int writeOffset = readOffset; - for (int i = 0; i + 1 < dataSize; i++) { - if ((bytes[readOffset - 1] & 0xFF) == 0xFF && bytes[readOffset] == 0) { - readOffset++; - dataSize--; - } - bytes[writeOffset++] = bytes[readOffset++]; - } - frame.setLimit(frame.limit() - (readOffset - writeOffset)); - System.arraycopy(bytes, readOffset, bytes, writeOffset, frame.bytesLeft() - readOffset); - flags &= ~2; - } - if (flags != previousFlags || unsignedIntDataSizeHack) { - int dataSizeOffset = frame.getPosition() - 6; - writeSyncSafeInteger(bytes, dataSizeOffset, dataSize); - bytes[dataSizeOffset + 4] = (byte) (flags >> 8); - bytes[dataSizeOffset + 5] = (byte) (flags & 0xFF); - } - frame.skipBytes(dataSize); - } - } - - private static void writeSyncSafeInteger(byte[] bytes, int offset, int value) { - bytes[offset] = (byte) ((value >> 21) & 0x7F); - bytes[offset + 1] = (byte) ((value >> 14) & 0x7F); - bytes[offset + 2] = (byte) ((value >> 7) & 0x7F); - bytes[offset + 3] = (byte) (value & 0x7F); - } - - private Id3Util() {} - -} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index ab501af1cb..9bdefeceaf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -27,6 +27,8 @@ import com.google.android.exoplayer2.extractor.MpegAudioHeader; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; @@ -57,6 +59,10 @@ public final class Mp3Extractor implements Extractor { * The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up. */ private static final int MAX_SNIFF_BYTES = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; + /** + * Maximum length of data read into {@link #scratch}. + */ + private static final int SCRATCH_LENGTH = 10; /** * Mask that includes the audio header values that must match between frames. @@ -77,6 +83,7 @@ public final class Mp3Extractor implements Extractor { private int synchronizedHeaderData; + private Metadata metadata; private Seeker seeker; private long basisTimeUs; private long samplesRead; @@ -97,7 +104,7 @@ public final class Mp3Extractor implements Extractor { */ public Mp3Extractor(long forcedFirstSampleTimestampUs) { this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs; - scratch = new ParsableByteArray(4); + scratch = new ParsableByteArray(SCRATCH_LENGTH); synchronizedHeader = new MpegAudioHeader(); gaplessInfoHolder = new GaplessInfoHolder(); basisTimeUs = C.TIME_UNSET; @@ -116,7 +123,7 @@ public final class Mp3Extractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { synchronizedHeaderData = 0; basisTimeUs = C.TIME_UNSET; samplesRead = 0; @@ -144,7 +151,7 @@ public final class Mp3Extractor implements Extractor { trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType, null, Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels, synchronizedHeader.sampleRate, Format.NO_VALUE, gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding, null, null, 0, null)); + gaplessInfoHolder.encoderPadding, null, null, 0, null, metadata)); } return readSample(input); } @@ -199,7 +206,7 @@ public final class Mp3Extractor implements Extractor { int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; input.resetPeekPosition(); if (input.getPosition() == 0) { - Id3Util.parseId3(input, gaplessInfoHolder); + peekId3Data(input); peekedId3Bytes = (int) input.getPeekPosition(); if (!sniffing) { input.skipFully(peekedId3Bytes); @@ -253,6 +260,45 @@ public final class Mp3Extractor implements Extractor { return true; } + /** + * Peeks ID3 data from the input, including gapless playback information. + * + * @param input The {@link ExtractorInput} from which data should be peeked. + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + private void peekId3Data(ExtractorInput input) throws IOException, InterruptedException { + int peekedId3Bytes = 0; + while (true) { + input.peekFully(scratch.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != Id3Decoder.ID3_TAG) { + // Not an ID3 tag. + break; + } + scratch.skipBytes(3); // Skip major version, minor version and flags. + int framesLength = scratch.readSynchSafeInt(); + int tagLength = Id3Decoder.ID3_HEADER_LENGTH + framesLength; + + if (metadata == null) { + byte[] id3Data = new byte[tagLength]; + System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); + metadata = new Id3Decoder().decode(id3Data, tagLength); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } + } else { + input.advancePeekPosition(framesLength); + } + + peekedId3Bytes += tagLength; + } + + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes); + } + /** * Returns a {@link Seeker} to seek using metadata read from {@code input}, which should provide * data from the start of the first frame in the stream. On returning, the input's position will diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index e93e9e3d9c..2eac7926e7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -52,6 +52,7 @@ import java.util.List; public static final int TYPE_d263 = Util.getIntegerCodeForString("d263"); public static final int TYPE_mdat = Util.getIntegerCodeForString("mdat"); public static final int TYPE_mp4a = Util.getIntegerCodeForString("mp4a"); + public static final int TYPE__mp3 = Util.getIntegerCodeForString(".mp3"); public static final int TYPE_wave = Util.getIntegerCodeForString("wave"); public static final int TYPE_lpcm = Util.getIntegerCodeForString("lpcm"); public static final int TYPE_sowt = Util.getIntegerCodeForString("sowt"); @@ -132,7 +133,7 @@ import java.util.List; public static final int TYPE_vp08 = Util.getIntegerCodeForString("vp08"); public static final int TYPE_vp09 = Util.getIntegerCodeForString("vp09"); public static final int TYPE_vpcC = Util.getIntegerCodeForString("vpcC"); - public static final int TYPE_DASHES = Util.getIntegerCodeForString("----"); + public static final int TYPE_camm = Util.getIntegerCodeForString("camm"); public final int type; @@ -299,7 +300,7 @@ import java.util.List; * @return The corresponding four character string. */ public static String getAtomTypeString(int type) { - return "" + (char) (type >> 24) + return "" + (char) ((type >> 24) & 0xFF) + (char) ((type >> 16) & 0xFF) + (char) ((type >> 8) & 0xFF) + (char) (type & 0xFF); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 0b2d5ec330..9dc0578263 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.audio.Ac3Util; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.CodecSpecificDataUtil; import com.google.android.exoplayer2.util.MimeTypes; @@ -30,6 +31,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.HevcConfig; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -48,6 +50,7 @@ import java.util.List; private static final int TYPE_subt = Util.getIntegerCodeForString("subt"); private static final int TYPE_clcp = Util.getIntegerCodeForString("clcp"); private static final int TYPE_cenc = Util.getIntegerCodeForString("cenc"); + private static final int TYPE_meta = Util.getIntegerCodeForString("meta"); /** * Parses a trak atom (defined in 14496-12). @@ -400,80 +403,54 @@ import java.util.List; * * @param udtaAtom The udta (user data) atom to decode. * @param isQuickTime True for QuickTime media. False otherwise. - * @param out {@link GaplessInfoHolder} to populate with gapless playback information. + * @return Parsed metadata, or null. */ - public static void parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime, GaplessInfoHolder out) { + public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { if (isQuickTime) { // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and // decode one. - return; + return null; } ParsableByteArray udtaData = udtaAtom.data; udtaData.setPosition(Atom.HEADER_SIZE); while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { + int atomPosition = udtaData.getPosition(); int atomSize = udtaData.readInt(); int atomType = udtaData.readInt(); if (atomType == Atom.TYPE_meta) { - udtaData.setPosition(udtaData.getPosition() - Atom.HEADER_SIZE); - udtaData.setLimit(udtaData.getPosition() + atomSize); - parseMetaAtom(udtaData, out); - break; + udtaData.setPosition(atomPosition); + return parseMetaAtom(udtaData, atomPosition + atomSize); } udtaData.skipBytes(atomSize - Atom.HEADER_SIZE); } + return null; } - private static void parseMetaAtom(ParsableByteArray data, GaplessInfoHolder out) { - data.skipBytes(Atom.FULL_HEADER_SIZE); - ParsableByteArray ilst = new ParsableByteArray(); - while (data.bytesLeft() >= Atom.HEADER_SIZE) { - int payloadSize = data.readInt() - Atom.HEADER_SIZE; - int atomType = data.readInt(); + private static Metadata parseMetaAtom(ParsableByteArray meta, int limit) { + meta.skipBytes(Atom.FULL_HEADER_SIZE); + while (meta.getPosition() < limit) { + int atomPosition = meta.getPosition(); + int atomSize = meta.readInt(); + int atomType = meta.readInt(); if (atomType == Atom.TYPE_ilst) { - ilst.reset(data.data, data.getPosition() + payloadSize); - ilst.setPosition(data.getPosition()); - parseIlst(ilst, out); - if (out.hasGaplessInfo()) { - return; - } + meta.setPosition(atomPosition); + return parseIlst(meta, atomPosition + atomSize); } - data.skipBytes(payloadSize); + meta.skipBytes(atomSize - Atom.HEADER_SIZE); } + return null; } - private static void parseIlst(ParsableByteArray ilst, GaplessInfoHolder out) { - while (ilst.bytesLeft() > 0) { - int position = ilst.getPosition(); - int endPosition = position + ilst.readInt(); - int type = ilst.readInt(); - if (type == Atom.TYPE_DASHES) { - String lastCommentMean = null; - String lastCommentName = null; - String lastCommentData = null; - while (ilst.getPosition() < endPosition) { - int length = ilst.readInt() - Atom.FULL_HEADER_SIZE; - int key = ilst.readInt(); - ilst.skipBytes(4); - if (key == Atom.TYPE_mean) { - lastCommentMean = ilst.readString(length); - } else if (key == Atom.TYPE_name) { - lastCommentName = ilst.readString(length); - } else if (key == Atom.TYPE_data) { - ilst.skipBytes(4); - lastCommentData = ilst.readString(length - 4); - } else { - ilst.skipBytes(length); - } - } - if (lastCommentName != null && lastCommentData != null - && "com.apple.iTunes".equals(lastCommentMean)) { - out.setFromComment(lastCommentName, lastCommentData); - break; - } - } else { - ilst.setPosition(endPosition); + private static Metadata parseIlst(ParsableByteArray ilst, int limit) { + ilst.skipBytes(Atom.HEADER_SIZE); + ArrayList entries = new ArrayList<>(); + while (ilst.getPosition() < limit) { + Metadata.Entry entry = MetadataUtil.parseIlstElement(ilst); + if (entry != null) { + entries.add(entry); } } + return entries.isEmpty() ? null : new Metadata(entries); } /** @@ -484,12 +461,9 @@ import java.util.List; */ private static long parseMvhd(ParsableByteArray mvhd) { mvhd.setPosition(Atom.HEADER_SIZE); - int fullAtom = mvhd.readInt(); int version = Atom.parseFullAtomVersion(fullAtom); - mvhd.skipBytes(version == 0 ? 8 : 16); - return mvhd.readUnsignedInt(); } @@ -568,6 +542,8 @@ import java.util.List; } else if (trackType == TYPE_text || trackType == TYPE_sbtl || trackType == TYPE_subt || trackType == TYPE_clcp) { return C.TRACK_TYPE_TEXT; + } else if (trackType == TYPE_meta) { + return C.TRACK_TYPE_METADATA; } else { return C.TRACK_TYPE_UNKNOWN; } @@ -627,7 +603,8 @@ import java.util.List; || childAtomType == Atom.TYPE_dtsc || childAtomType == Atom.TYPE_dtse || childAtomType == Atom.TYPE_dtsh || childAtomType == Atom.TYPE_dtsl || childAtomType == Atom.TYPE_samr || childAtomType == Atom.TYPE_sawb - || childAtomType == Atom.TYPE_lpcm || childAtomType == Atom.TYPE_sowt) { + || childAtomType == Atom.TYPE_lpcm || childAtomType == Atom.TYPE_sowt + || childAtomType == Atom.TYPE__mp3) { parseAudioSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, language, isQuickTime, drmInitData, out, i); } else if (childAtomType == Atom.TYPE_TTML) { @@ -644,9 +621,13 @@ import java.util.List; MimeTypes.APPLICATION_TTML, null, Format.NO_VALUE, 0, language, drmInitData, 0 /* subsample timing is absolute */); } else if (childAtomType == Atom.TYPE_c608) { + // Defined by the QuickTime File Format specification. out.format = Format.createTextSampleFormat(Integer.toString(trackId), - MimeTypes.APPLICATION_CEA608, null, Format.NO_VALUE, 0, language, drmInitData); + MimeTypes.APPLICATION_MP4CEA608, null, Format.NO_VALUE, 0, language, drmInitData); out.requiredSampleTransformation = Track.TRANSFORMATION_CEA608_CDAT; + } else if (childAtomType == Atom.TYPE_camm) { + out.format = Format.createSampleFormat(Integer.toString(trackId), + MimeTypes.APPLICATION_CAMERA_MOTION, null, Format.NO_VALUE, drmInitData); } stsd.setPosition(childStartPosition + childAtomSize); } @@ -856,6 +837,8 @@ import java.util.List; mimeType = MimeTypes.AUDIO_AMR_WB; } else if (atomType == Atom.TYPE_lpcm || atomType == Atom.TYPE_sowt) { mimeType = MimeTypes.AUDIO_RAW; + } else if (atomType == Atom.TYPE__mp3) { + mimeType = MimeTypes.AUDIO_MPEG; } byte[] initializationData = null; diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 9966125c46..c718cd7111 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -194,7 +194,7 @@ public final class FragmentedMp4Extractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { int trackCount = trackBundles.size(); for (int i = 0; i < trackCount; i++) { trackBundles.valueAt(i).reset(); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java new file mode 100644 index 0000000000..e99dab053b --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.mp4; + +import android.util.Log; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; +import com.google.android.exoplayer2.metadata.id3.Id3Frame; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; + +/** + * Parses metadata items stored in ilst atoms. + */ +/* package */ final class MetadataUtil { + + private static final String TAG = "MetadataUtil"; + + // Codes that start with the copyright character (omitted) and have equivalent ID3 frames. + private static final int SHORT_TYPE_NAME_1 = Util.getIntegerCodeForString("nam"); + private static final int SHORT_TYPE_NAME_2 = Util.getIntegerCodeForString("trk"); + private static final int SHORT_TYPE_COMMENT = Util.getIntegerCodeForString("cmt"); + private static final int SHORT_TYPE_YEAR = Util.getIntegerCodeForString("day"); + private static final int SHORT_TYPE_ARTIST = Util.getIntegerCodeForString("ART"); + private static final int SHORT_TYPE_ENCODER = Util.getIntegerCodeForString("too"); + private static final int SHORT_TYPE_ALBUM = Util.getIntegerCodeForString("alb"); + private static final int SHORT_TYPE_COMPOSER_1 = Util.getIntegerCodeForString("com"); + private static final int SHORT_TYPE_COMPOSER_2 = Util.getIntegerCodeForString("wrt"); + private static final int SHORT_TYPE_LYRICS = Util.getIntegerCodeForString("lyr"); + private static final int SHORT_TYPE_GENRE = Util.getIntegerCodeForString("gen"); + + // Codes that have equivalent ID3 frames. + private static final int TYPE_COVER_ART = Util.getIntegerCodeForString("covr"); + private static final int TYPE_GENRE = Util.getIntegerCodeForString("gnre"); + private static final int TYPE_GROUPING = Util.getIntegerCodeForString("grp"); + private static final int TYPE_DISK_NUMBER = Util.getIntegerCodeForString("disk"); + private static final int TYPE_TRACK_NUMBER = Util.getIntegerCodeForString("trkn"); + private static final int TYPE_TEMPO = Util.getIntegerCodeForString("tmpo"); + private static final int TYPE_COMPILATION = Util.getIntegerCodeForString("cpil"); + private static final int TYPE_ALBUM_ARTIST = Util.getIntegerCodeForString("aART"); + private static final int TYPE_SORT_TRACK_NAME = Util.getIntegerCodeForString("sonm"); + private static final int TYPE_SORT_ALBUM = Util.getIntegerCodeForString("soal"); + private static final int TYPE_SORT_ARTIST = Util.getIntegerCodeForString("soar"); + private static final int TYPE_SORT_ALBUM_ARTIST = Util.getIntegerCodeForString("soaa"); + private static final int TYPE_SORT_COMPOSER = Util.getIntegerCodeForString("soco"); + + // Types that do not have equivalent ID3 frames. + private static final int TYPE_RATING = Util.getIntegerCodeForString("rtng"); + private static final int TYPE_GAPLESS_ALBUM = Util.getIntegerCodeForString("pgap"); + private static final int TYPE_TV_SORT_SHOW = Util.getIntegerCodeForString("sosn"); + private static final int TYPE_TV_SHOW = Util.getIntegerCodeForString("tvsh"); + + // Type for items that are intended for internal use by the player. + private static final int TYPE_INTERNAL = Util.getIntegerCodeForString("----"); + + // Standard genres. + private static final String[] STANDARD_GENRES = new String[] { + // These are the official ID3v1 genres. + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", + "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", + "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", + "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental", + "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", + "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", + "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", + "Native American", "Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", + "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", + "Hard Rock", + // These were made up by the authors of Winamp and later added to the ID3 spec. + "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival", + "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock", + "Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour", + "Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", + "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad", + "Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", + "Euro-House", "Dance Hall", + // These were med up by the authors of Winamp but have not been added to the ID3 spec. + "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "BritPop", "Negerpunk", + "Polsk Punk", "Beat", "Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover", + "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal", "Anime", + "Jpop", "Synthpop" + }; + + private static final String LANGUAGE_UNDEFINED = "und"; + + private MetadataUtil() {} + + /** + * Parses a single ilst element from a {@link ParsableByteArray}. The element is read starting + * from the current position of the {@link ParsableByteArray}, and the position is advanced by + * the size of the element. The position is advanced even if the element's type is unrecognized. + * + * @param ilst Holds the data to be parsed. + * @return The parsed element, or null if the element's type was not recognized. + */ + public static Metadata.Entry parseIlstElement(ParsableByteArray ilst) { + int position = ilst.getPosition(); + int endPosition = position + ilst.readInt(); + int type = ilst.readInt(); + int typeTopByte = (type >> 24) & 0xFF; + try { + if (typeTopByte == '\u00A9' /* Copyright char */ + || typeTopByte == '\uFFFD' /* Replacement char */) { + int shortType = type & 0x00FFFFFF; + if (shortType == SHORT_TYPE_COMMENT) { + return parseCommentAttribute(type, ilst); + } else if (shortType == SHORT_TYPE_NAME_1 || shortType == SHORT_TYPE_NAME_2) { + return parseTextAttribute(type, "TIT2", ilst); + } else if (shortType == SHORT_TYPE_COMPOSER_1 || shortType == SHORT_TYPE_COMPOSER_2) { + return parseTextAttribute(type, "TCOM", ilst); + } else if (shortType == SHORT_TYPE_YEAR) { + return parseTextAttribute(type, "TDRC", ilst); + } else if (shortType == SHORT_TYPE_ARTIST) { + return parseTextAttribute(type, "TPE1", ilst); + } else if (shortType == SHORT_TYPE_ENCODER) { + return parseTextAttribute(type, "TSSE", ilst); + } else if (shortType == SHORT_TYPE_ALBUM) { + return parseTextAttribute(type, "TALB", ilst); + } else if (shortType == SHORT_TYPE_LYRICS) { + return parseTextAttribute(type, "USLT", ilst); + } else if (shortType == SHORT_TYPE_GENRE) { + return parseTextAttribute(type, "TCON", ilst); + } else if (shortType == TYPE_GROUPING) { + return parseTextAttribute(type, "TIT1", ilst); + } + } else if (type == TYPE_GENRE) { + return parseStandardGenreAttribute(ilst); + } else if (type == TYPE_DISK_NUMBER) { + return parseIndexAndCountAttribute(type, "TPOS", ilst); + } else if (type == TYPE_TRACK_NUMBER) { + return parseIndexAndCountAttribute(type, "TRCK", ilst); + } else if (type == TYPE_TEMPO) { + return parseUint8Attribute(type, "TBPM", ilst, true, false); + } else if (type == TYPE_COMPILATION) { + return parseUint8Attribute(type, "TCMP", ilst, true, true); + } else if (type == TYPE_COVER_ART) { + return parseCoverArt(ilst); + } else if (type == TYPE_ALBUM_ARTIST) { + return parseTextAttribute(type, "TPE2", ilst); + } else if (type == TYPE_SORT_TRACK_NAME) { + return parseTextAttribute(type, "TSOT", ilst); + } else if (type == TYPE_SORT_ALBUM) { + return parseTextAttribute(type, "TSO2", ilst); + } else if (type == TYPE_SORT_ARTIST) { + return parseTextAttribute(type, "TSOA", ilst); + } else if (type == TYPE_SORT_ALBUM_ARTIST) { + return parseTextAttribute(type, "TSOP", ilst); + } else if (type == TYPE_SORT_COMPOSER) { + return parseTextAttribute(type, "TSOC", ilst); + } else if (type == TYPE_RATING) { + return parseUint8Attribute(type, "ITUNESADVISORY", ilst, false, false); + } else if (type == TYPE_GAPLESS_ALBUM) { + return parseUint8Attribute(type, "ITUNESGAPLESS", ilst, false, true); + } else if (type == TYPE_TV_SORT_SHOW) { + return parseTextAttribute(type, "TVSHOWSORT", ilst); + } else if (type == TYPE_TV_SHOW) { + return parseTextAttribute(type, "TVSHOW", ilst); + } else if (type == TYPE_INTERNAL) { + return parseInternalAttribute(ilst, endPosition); + } + Log.d(TAG, "Skipped unknown metadata entry: " + Atom.getAtomTypeString(type)); + return null; + } finally { + ilst.setPosition(endPosition); + } + } + + private static TextInformationFrame parseTextAttribute(int type, String id, + ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new TextInformationFrame(id, value); + } + Log.w(TAG, "Failed to parse text attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static CommentFrame parseCommentAttribute(int type, ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new CommentFrame(LANGUAGE_UNDEFINED, value, value); + } + Log.w(TAG, "Failed to parse comment attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static Id3Frame parseUint8Attribute(int type, String id, ParsableByteArray data, + boolean isTextInformationFrame, boolean isBoolean) { + int value = parseUint8AttributeValue(data); + if (isBoolean) { + value = Math.min(1, value); + } + if (value >= 0) { + return isTextInformationFrame ? new TextInformationFrame(id, Integer.toString(value)) + : new CommentFrame(LANGUAGE_UNDEFINED, id, Integer.toString(value)); + } + Log.w(TAG, "Failed to parse uint8 attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static TextInformationFrame parseIndexAndCountAttribute(int type, String attributeName, + ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data && atomSize >= 22) { + data.skipBytes(10); // version (1), flags (3), empty (4), empty (2) + int index = data.readUnsignedShort(); + if (index > 0) { + String description = "" + index; + int count = data.readUnsignedShort(); + if (count > 0) { + description += "/" + count; + } + return new TextInformationFrame(attributeName, description); + } + } + Log.w(TAG, "Failed to parse index/count attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + private static TextInformationFrame parseStandardGenreAttribute(ParsableByteArray data) { + int genreCode = parseUint8AttributeValue(data); + String genreString = (0 < genreCode && genreCode <= STANDARD_GENRES.length) + ? STANDARD_GENRES[genreCode - 1] : null; + if (genreString != null) { + return new TextInformationFrame("TCON", genreString); + } + Log.w(TAG, "Failed to parse standard genre code"); + return null; + } + + private static ApicFrame parseCoverArt(ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + int fullVersionInt = data.readInt(); + int flags = Atom.parseFullAtomFlags(fullVersionInt); + String mimeType = flags == 13 ? "image/jpeg" : flags == 14 ? "image/png" : null; + if (mimeType == null) { + Log.w(TAG, "Unrecognized cover art flags: " + flags); + return null; + } + data.skipBytes(4); // empty (4) + byte[] pictureData = new byte[atomSize - 16]; + data.readBytes(pictureData, 0, pictureData.length); + return new ApicFrame(mimeType, null, 3 /* Cover (front) */, pictureData); + } + Log.w(TAG, "Failed to parse cover art attribute"); + return null; + } + + private static Id3Frame parseInternalAttribute(ParsableByteArray data, int endPosition) { + String domain = null; + String name = null; + int dataAtomPosition = -1; + int dataAtomSize = -1; + while (data.getPosition() < endPosition) { + int atomPosition = data.getPosition(); + int atomSize = data.readInt(); + int atomType = data.readInt(); + data.skipBytes(4); // version (1), flags (3) + if (atomType == Atom.TYPE_mean) { + domain = data.readNullTerminatedString(atomSize - 12); + } else if (atomType == Atom.TYPE_name) { + name = data.readNullTerminatedString(atomSize - 12); + } else { + if (atomType == Atom.TYPE_data) { + dataAtomPosition = atomPosition; + dataAtomSize = atomSize; + } + data.skipBytes(atomSize - 12); + } + } + if (!"com.apple.iTunes".equals(domain) || !"iTunSMPB".equals(name) || dataAtomPosition == -1) { + // We're only interested in iTunSMPB. + return null; + } + data.setPosition(dataAtomPosition); + data.skipBytes(16); // size (4), type (4), version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(dataAtomSize - 16); + return new CommentFrame(LANGUAGE_UNDEFINED, name, value); + } + + private static int parseUint8AttributeValue(ParsableByteArray data) { + data.skipBytes(4); // atomSize + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + return data.readUnsignedByte(); + } + Log.w(TAG, "Failed to parse uint8 attribute value"); + return -1; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index 467ec7a4fa..3759a80fd4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import android.support.annotation.IntDef; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.ParserException; @@ -27,11 +28,14 @@ import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; import java.util.Stack; @@ -53,11 +57,15 @@ public final class Mp4Extractor implements Extractor, SeekMap { }; - // Parser states. - private static final int STATE_AFTER_SEEK = 0; - private static final int STATE_READING_ATOM_HEADER = 1; - private static final int STATE_READING_ATOM_PAYLOAD = 2; - private static final int STATE_READING_SAMPLE = 3; + /** + * Parser states. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_READING_ATOM_HEADER, STATE_READING_ATOM_PAYLOAD, STATE_READING_SAMPLE}) + private @interface State {} + private static final int STATE_READING_ATOM_HEADER = 0; + private static final int STATE_READING_ATOM_PAYLOAD = 1; + private static final int STATE_READING_SAMPLE = 2; // Brand stored in the ftyp atom for QuickTime media. private static final int BRAND_QUICKTIME = Util.getIntegerCodeForString("qt "); @@ -75,6 +83,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { private final ParsableByteArray atomHeader; private final Stack containerAtoms; + @State private int parserState; private int atomType; private long atomSize; @@ -95,7 +104,6 @@ public final class Mp4Extractor implements Extractor, SeekMap { containerAtoms = new Stack<>(); nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); nalLength = new ParsableByteArray(4); - enterReadingAtomHeaderState(); } @Override @@ -109,12 +117,16 @@ public final class Mp4Extractor implements Extractor, SeekMap { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { containerAtoms.clear(); atomHeaderBytesRead = 0; sampleBytesWritten = 0; sampleCurrentNalBytesRemaining = 0; - parserState = STATE_AFTER_SEEK; + if (position == 0) { + enterReadingAtomHeaderState(); + } else if (tracks != null) { + updateSampleIndices(timeUs); + } } @Override @@ -127,13 +139,6 @@ public final class Mp4Extractor implements Extractor, SeekMap { throws IOException, InterruptedException { while (true) { switch (parserState) { - case STATE_AFTER_SEEK: - if (input.getPosition() == 0) { - enterReadingAtomHeaderState(); - } else { - parserState = STATE_READING_SAMPLE; - } - break; case STATE_READING_ATOM_HEADER: if (!readAtomHeader(input)) { return RESULT_END_OF_INPUT; @@ -144,8 +149,10 @@ public final class Mp4Extractor implements Extractor, SeekMap { return RESULT_SEEK; } break; - default: + case STATE_READING_SAMPLE: return readSample(input, seekPosition); + default: + throw new IllegalStateException(); } } } @@ -172,8 +179,6 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Handle the case where the requested time is before the first synchronization sample. sampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(timeUs); } - track.sampleIndex = sampleIndex; - long offset = sampleTable.offsets[sampleIndex]; if (offset < earliestSamplePosition) { earliestSamplePosition = offset; @@ -310,10 +315,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { List tracks = new ArrayList<>(); long earliestSampleOffset = Long.MAX_VALUE; + Metadata metadata = null; GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder(); Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta); if (udta != null) { - AtomParsers.parseUdta(udta, isQuickTime, gaplessInfoHolder); + metadata = AtomParsers.parseUdta(udta, isQuickTime); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } } for (int i = 0; i < moov.containerChildren.size(); i++) { @@ -340,9 +349,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Allow ten source samples per output sample, like the platform extractor. int maxInputSize = trackSampleTable.maximumSize + 3 * 10; Format format = track.format.copyWithMaxInputSize(maxInputSize); - if (track.type == C.TRACK_TYPE_AUDIO && gaplessInfoHolder.hasGaplessInfo()) { - format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding); + if (track.type == C.TRACK_TYPE_AUDIO) { + if (gaplessInfoHolder.hasGaplessInfo()) { + format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay, + gaplessInfoHolder.encoderPadding); + } + if (metadata != null) { + format = format.copyWithMetadata(metadata); + } } mp4Track.trackOutput.format(format); @@ -468,6 +482,21 @@ public final class Mp4Extractor implements Extractor, SeekMap { return earliestSampleTrackIndex; } + /** + * Updates every track's sample index to point its latest sync sample before/at {@code timeUs}. + */ + private void updateSampleIndices(long timeUs) { + for (Mp4Track track : tracks) { + TrackSampleTable sampleTable = track.sampleTable; + int sampleIndex = sampleTable.getIndexOfEarlierOrEqualSynchronizationSample(timeUs); + if (sampleIndex == C.INDEX_UNSET) { + // Handle the case where the requested time is before the first synchronization sample. + sampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(timeUs); + } + track.sampleIndex = sampleIndex; + } + } + /** * Returns whether the extractor should decode a leaf atom with type {@code atom}. */ diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java index c6e5d46b8e..5470e2badc 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java @@ -45,7 +45,6 @@ import java.io.IOException; private int state; private long totalGranules; - private volatile long queriedGranule; private long positionBeforeSeekToEnd; private long targetGranule; @@ -59,13 +58,21 @@ import java.io.IOException; * @param startPosition Start position of the payload (inclusive). * @param endPosition End position of the payload (exclusive). * @param streamReader StreamReader instance which owns this OggSeeker + * @param firstPayloadPageSize The total size of the first payload page, in bytes. + * @param firstPayloadPageGranulePosition The granule position of the first payload page. */ - public DefaultOggSeeker(long startPosition, long endPosition, StreamReader streamReader) { + public DefaultOggSeeker(long startPosition, long endPosition, StreamReader streamReader, + int firstPayloadPageSize, long firstPayloadPageGranulePosition) { Assertions.checkArgument(startPosition >= 0 && endPosition > startPosition); this.streamReader = streamReader; this.startPosition = startPosition; this.endPosition = endPosition; - this.state = STATE_SEEK_TO_END; + if (firstPayloadPageSize == endPosition - startPosition) { + totalGranules = firstPayloadPageGranulePosition; + state = STATE_IDLE; + } else { + state = STATE_SEEK_TO_END; + } } @Override @@ -77,9 +84,9 @@ import java.io.IOException; positionBeforeSeekToEnd = input.getPosition(); state = STATE_READ_LAST_PAGE; // Seek to the end just before the last page of stream to get the duration. - long lastPagePosition = endPosition - OggPageHeader.MAX_PAGE_SIZE; - if (lastPagePosition > positionBeforeSeekToEnd) { - return lastPagePosition; + long lastPageSearchPosition = endPosition - OggPageHeader.MAX_PAGE_SIZE; + if (lastPageSearchPosition > positionBeforeSeekToEnd) { + return lastPageSearchPosition; } // Fall through. case STATE_READ_LAST_PAGE: @@ -106,9 +113,9 @@ import java.io.IOException; } @Override - public long startSeek() { + public long startSeek(long timeUs) { Assertions.checkArgument(state == STATE_IDLE || state == STATE_SEEK); - targetGranule = queriedGranule; + targetGranule = timeUs == 0 ? 0 : streamReader.convertTimeToGranule(timeUs); state = STATE_SEEK; resetSeeking(); return targetGranule; @@ -214,11 +221,10 @@ import java.io.IOException; @Override public long getPosition(long timeUs) { if (timeUs == 0) { - queriedGranule = 0; return startPosition; } - queriedGranule = streamReader.convertTimeToGranule(timeUs); - return getEstimatedPosition(startPosition, queriedGranule, DEFAULT_OFFSET); + long granule = streamReader.convertTimeToGranule(timeUs); + return getEstimatedPosition(startPosition, granule, DEFAULT_OFFSET); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java index 0353933982..f4da6e3960 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java @@ -127,12 +127,15 @@ import java.util.List; private static final int METADATA_LENGTH_OFFSET = 1; private static final int SEEK_POINT_SIZE = 18; - private long[] sampleNumbers; - private long[] offsets; - private long firstFrameOffset = -1; - private volatile long queriedGranule; - private volatile long seekedGranule; - private long currentGranule = -1; + private long[] seekPointGranules; + private long[] seekPointOffsets; + private long firstFrameOffset; + private long pendingSeekGranule; + + public FlacOggSeeker() { + firstFrameOffset = -1; + pendingSeekGranule = -1; + } public void setFirstFrameOffset(long firstFrameOffset) { this.firstFrameOffset = firstFrameOffset; @@ -141,40 +144,40 @@ import java.util.List; /** * Parses a FLAC file seek table metadata structure and initializes internal fields. * - * @param data - * A ParsableByteArray including whole seek table metadata block. Its position should be set - * to the beginning of the block. + * @param data A {@link ParsableByteArray} including whole seek table metadata block. Its + * position should be set to the beginning of the block. * @see FLAC format - * METADATA_BLOCK_SEEKTABLE + * METADATA_BLOCK_SEEKTABLE */ public void parseSeekTable(ParsableByteArray data) { data.skipBytes(METADATA_LENGTH_OFFSET); int length = data.readUnsignedInt24(); int numberOfSeekPoints = length / SEEK_POINT_SIZE; - - sampleNumbers = new long[numberOfSeekPoints]; - offsets = new long[numberOfSeekPoints]; - + seekPointGranules = new long[numberOfSeekPoints]; + seekPointOffsets = new long[numberOfSeekPoints]; for (int i = 0; i < numberOfSeekPoints; i++) { - sampleNumbers[i] = data.readLong(); - offsets[i] = data.readLong(); + seekPointGranules[i] = data.readLong(); + seekPointOffsets[i] = data.readLong(); data.skipBytes(2); // Skip "Number of samples in the target frame." } } @Override public long read(ExtractorInput input) throws IOException, InterruptedException { - if (currentGranule >= 0) { - currentGranule = -currentGranule - 2; - return currentGranule; + if (pendingSeekGranule >= 0) { + long result = -(pendingSeekGranule + 2); + pendingSeekGranule = -1; + return result; } return -1; } @Override - public synchronized long startSeek() { - currentGranule = seekedGranule; - return queriedGranule; + public long startSeek(long timeUs) { + long granule = convertTimeToGranule(timeUs); + int index = Util.binarySearchFloor(seekPointGranules, granule, true, true); + pendingSeekGranule = seekPointGranules[index]; + return granule; } @Override @@ -188,11 +191,10 @@ import java.util.List; } @Override - public synchronized long getPosition(long timeUs) { - queriedGranule = convertTimeToGranule(timeUs); - int index = Util.binarySearchFloor(sampleNumbers, queriedGranule, true, true); - seekedGranule = sampleNumbers[index]; - return firstFrameOffset + offsets[index]; + public long getPosition(long timeUs) { + long granule = convertTimeToGranule(timeUs); + int index = Util.binarySearchFloor(seekPointGranules, granule, true, true); + return firstFrameOffset + seekPointOffsets[index]; } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java index 116dedf1ce..5f41126737 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java @@ -82,8 +82,8 @@ public class OggExtractor implements Extractor { } @Override - public void seek(long position) { - streamReader.seek(position); + public void seek(long position, long timeUs) { + streamReader.seek(position, timeUs); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java index efe6ed0fdf..aa88e5bf89 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java @@ -35,9 +35,10 @@ import java.io.IOException; /** * Initializes a seek operation. * + * @param timeUs The seek position in microseconds. * @return The granule position targeted by the seek. */ - long startSeek(); + long startSeek(long timeUs); /** * Reads data from the {@link ExtractorInput} to build the {@link SeekMap} or to continue a diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java index 108743c764..8ed8a4a01d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java @@ -81,7 +81,7 @@ import java.util.List; setupData.format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_OPUS, null, Format.NO_VALUE, Format.NO_VALUE, channelCount, SAMPLE_RATE, initializationData, null, 0, - "und"); + null); headerRead = true; } else { boolean headerPacket = packet.readInt() == OPUS_CODE; diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/StreamReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/StreamReader.java index aa3f8e2353..6424155bd9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/StreamReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/StreamReader.java @@ -81,15 +81,15 @@ import java.io.IOException; } /** - * @see Extractor#seek(long) + * @see Extractor#seek(long, long) */ - final void seek(long position) { + final void seek(long position, long timeUs) { oggPacket.reset(); if (position == 0) { reset(!seekMapSet); } else { if (state != STATE_READ_HEADERS) { - targetGranule = oggSeeker.startSeek(); + targetGranule = oggSeeker.startSeek(timeUs); state = STATE_READ_PAYLOAD; } } @@ -144,7 +144,10 @@ import java.io.IOException; } else if (input.getLength() == C.LENGTH_UNSET) { oggSeeker = new UnseekableOggSeeker(); } else { - oggSeeker = new DefaultOggSeeker(payloadStartPosition, input.getLength(), this); + OggPageHeader firstPayloadPageHeader = oggPacket.getPageHeader(); + oggSeeker = new DefaultOggSeeker(payloadStartPosition, input.getLength(), this, + firstPayloadPageHeader.headerSize + firstPayloadPageHeader.bodySize, + firstPayloadPageHeader.granulePosition); } setupData = null; @@ -159,7 +162,7 @@ import java.io.IOException; seekPosition.position = position; return Extractor.RESULT_SEEK; } else if (position < -1) { - onSeekEnd(-position - 2); + onSeekEnd(-(position + 2)); } if (!seekMapSet) { SeekMap seekMap = oggSeeker.createSeekMap(); @@ -229,7 +232,7 @@ import java.io.IOException; /** * Called on end of seeking. * - * @param currentGranule Current granule at the current position of input. + * @param currentGranule The granule at the current input position. */ protected void onSeekEnd(long currentGranule) { this.currentGranule = currentGranule; @@ -243,7 +246,7 @@ import java.io.IOException; } @Override - public long startSeek() { + public long startSeek(long timeUs) { return 0; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java index c6c9efc0f7..ae52e80299 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisBitArray.java @@ -26,7 +26,7 @@ import com.google.android.exoplayer2.util.Assertions; /* package */ final class VorbisBitArray { public final byte[] data; - private int limit; + private final int limit; private int byteOffset; private int bitOffset; diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java index ea9458a657..f6cd29aff2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -45,9 +44,10 @@ public final class RawCcExtractor implements Extractor { private static final int STATE_READING_TIMESTAMP_AND_COUNT = 1; private static final int STATE_READING_SAMPLES = 2; + private final Format format; + private final ParsableByteArray dataScratch; - private ExtractorOutput extractorOutput; private TrackOutput trackOutput; private int parserState; @@ -56,20 +56,18 @@ public final class RawCcExtractor implements Extractor { private int remainingSampleCount; private int sampleBytesWritten; - public RawCcExtractor() { + public RawCcExtractor(Format format) { + this.format = format; dataScratch = new ParsableByteArray(SCRATCH_SIZE); parserState = STATE_READING_HEADER; } @Override public void init(ExtractorOutput output) { - this.extractorOutput = output; - extractorOutput.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); - trackOutput = extractorOutput.track(0); - extractorOutput.endTracks(); - - trackOutput.format(Format.createTextSampleFormat(null, MimeTypes.APPLICATION_CEA608, - null, Format.NO_VALUE, 0, null, null)); + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + trackOutput = output.track(0); + output.endTracks(); + trackOutput.format(format); } @Override @@ -107,7 +105,7 @@ public final class RawCcExtractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { parserState = STATE_READING_HEADER; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java index 7fc8b429a8..e714928c20 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java @@ -23,7 +23,7 @@ import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.TrackIdGenerator; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; @@ -119,13 +119,13 @@ public final class Ac3Extractor implements Extractor { @Override public void init(ExtractorOutput output) { reader = new Ac3Reader(); // TODO: Add support for embedded ID3. - reader.init(output, new TrackIdGenerator(0, 1)); + reader.createTracks(output, new TrackIdGenerator(0, 1)); output.endTracks(); output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { startedPacket = false; reader.seek(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java index a9d3319f87..52faa8c673 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java @@ -20,13 +20,14 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.Ac3Util; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; /** * Parses a continuous (E-)AC-3 byte stream and extracts individual samples. */ -/* package */ final class Ac3Reader extends ElementaryStreamReader { +/* package */ final class Ac3Reader implements ElementaryStreamReader { private static final int STATE_FINDING_SYNC = 0; private static final int STATE_READING_HEADER = 1; @@ -82,7 +83,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator generator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator generator) { output = extractorOutput.track(generator.getNextId()); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java index 7a9cbd4bb1..f7dadd51b2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java @@ -22,7 +22,7 @@ import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.TrackIdGenerator; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; @@ -128,13 +128,13 @@ public final class AdtsExtractor implements Extractor { @Override public void init(ExtractorOutput output) { reader = new AdtsReader(true); - reader.init(output, new TrackIdGenerator(0, 1)); + reader.createTracks(output, new TrackIdGenerator(0, 1)); output.endTracks(); output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { startedPacket = false; reader.seek(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java index d0474f7e44..47cb217fc7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.CodecSpecificDataUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableBitArray; @@ -32,7 +33,7 @@ import java.util.Collections; /** * Parses a continuous ADTS byte stream and extracts individual frames. */ -/* package */ final class AdtsReader extends ElementaryStreamReader { +/* package */ final class AdtsReader implements ElementaryStreamReader { private static final String TAG = "AdtsReader"; @@ -106,7 +107,7 @@ import java.util.Collections; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); if (exposeId3) { id3Output = extractorOutput.track(idGenerator.getNextId()); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultStreamReaderFactory.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java similarity index 54% rename from library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultStreamReaderFactory.java rename to library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java index 58a0e55f02..31aa88d11a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultStreamReaderFactory.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java @@ -16,67 +16,80 @@ package com.google.android.exoplayer2.extractor.ts; import android.support.annotation.IntDef; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.EsInfo; +import android.util.SparseArray; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** - * Default implementation for {@link ElementaryStreamReader.Factory}. + * Default implementation for {@link TsPayloadReader.Factory}. */ -public final class DefaultStreamReaderFactory implements ElementaryStreamReader.Factory { +public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Factory { /** * Flags controlling elementary stream readers behaviour. */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = {FLAG_ALLOW_NON_IDR_KEYFRAMES, FLAG_IGNORE_AAC_STREAM, - FLAG_IGNORE_H264_STREAM, FLAG_DETECT_ACCESS_UNITS}) + FLAG_IGNORE_H264_STREAM, FLAG_DETECT_ACCESS_UNITS, FLAG_IGNORE_SPLICE_INFO_STREAM}) public @interface Flags { } public static final int FLAG_ALLOW_NON_IDR_KEYFRAMES = 1; public static final int FLAG_IGNORE_AAC_STREAM = 2; public static final int FLAG_IGNORE_H264_STREAM = 4; public static final int FLAG_DETECT_ACCESS_UNITS = 8; + public static final int FLAG_IGNORE_SPLICE_INFO_STREAM = 16; @Flags private final int flags; - public DefaultStreamReaderFactory() { + public DefaultTsPayloadReaderFactory() { this(0); } - public DefaultStreamReaderFactory(@Flags int flags) { + public DefaultTsPayloadReaderFactory(@Flags int flags) { this.flags = flags; } @Override - public ElementaryStreamReader createStreamReader(int streamType, EsInfo esInfo) { + public SparseArray createInitialPayloadReaders() { + return new SparseArray<>(); + } + + @Override + public TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo) { switch (streamType) { case TsExtractor.TS_STREAM_TYPE_MPA: case TsExtractor.TS_STREAM_TYPE_MPA_LSF: - return new MpegAudioReader(esInfo.language); + return new PesReader(new MpegAudioReader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_AAC: - return (flags & FLAG_IGNORE_AAC_STREAM) != 0 ? null - : new AdtsReader(false, esInfo.language); + return isSet(FLAG_IGNORE_AAC_STREAM) + ? null : new PesReader(new AdtsReader(false, esInfo.language)); case TsExtractor.TS_STREAM_TYPE_AC3: case TsExtractor.TS_STREAM_TYPE_E_AC3: - return new Ac3Reader(esInfo.language); + return new PesReader(new Ac3Reader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_DTS: case TsExtractor.TS_STREAM_TYPE_HDMV_DTS: - return new DtsReader(esInfo.language); + return new PesReader(new DtsReader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_H262: - return new H262Reader(); + return new PesReader(new H262Reader()); case TsExtractor.TS_STREAM_TYPE_H264: - return (flags & FLAG_IGNORE_H264_STREAM) != 0 ? null - : new H264Reader((flags & FLAG_ALLOW_NON_IDR_KEYFRAMES) != 0, - (flags & FLAG_DETECT_ACCESS_UNITS) != 0); + return isSet(FLAG_IGNORE_H264_STREAM) ? null : new PesReader( + new H264Reader(isSet(FLAG_ALLOW_NON_IDR_KEYFRAMES), isSet(FLAG_DETECT_ACCESS_UNITS))); case TsExtractor.TS_STREAM_TYPE_H265: - return new H265Reader(); + return new PesReader(new H265Reader()); + case TsExtractor.TS_STREAM_TYPE_SPLICE_INFO: + return isSet(FLAG_IGNORE_SPLICE_INFO_STREAM) + ? null : new SectionReader(new SpliceInfoSectionReader()); case TsExtractor.TS_STREAM_TYPE_ID3: - return new Id3Reader(); + return new PesReader(new Id3Reader()); default: return null; } } + private boolean isSet(@Flags int flag) { + return (flags & flag) != 0; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java index 42223ef285..9707685295 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java @@ -20,12 +20,13 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.DtsUtil; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableByteArray; /** * Parses a continuous DTS byte stream and extracts individual samples. */ -/* package */ final class DtsReader extends ElementaryStreamReader { +/* package */ final class DtsReader implements ElementaryStreamReader { private static final int STATE_FINDING_SYNC = 0; private static final int STATE_READING_HEADER = 1; @@ -77,7 +78,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java index e2efbebb43..57bcf31fc5 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java @@ -22,82 +22,21 @@ import com.google.android.exoplayer2.util.ParsableByteArray; /** * Extracts individual samples from an elementary media stream, preserving original order. */ -public abstract class ElementaryStreamReader { - - /** - * Factory of {@link ElementaryStreamReader} instances. - */ - public interface Factory { - - /** - * Returns an {@link ElementaryStreamReader} for a given PMT entry. May return null if the - * stream type is not supported or if the stream already has a reader assigned to it. - * - * @param streamType Stream type value as defined in the PMT entry or associated descriptors. - * @param esInfo Information associated to the elementary stream provided in the PMT. - * @return An {@link ElementaryStreamReader} for the elementary streams carried by the provided - * pid. {@code null} if the stream is not supported or if it should be ignored. - */ - ElementaryStreamReader createStreamReader(int streamType, EsInfo esInfo); - - } - - /** - * Holds descriptor information associated with an elementary stream. - */ - public static final class EsInfo { - - public final int streamType; - public String language; - public byte[] descriptorBytes; - - /** - * @param streamType The type of the stream as defined by the - * {@link TsExtractor}{@code .TS_STREAM_TYPE_*}. - * @param language The language of the stream, as defined by ISO/IEC 13818-1, section 2.6.18. - * @param descriptorBytes The descriptor bytes associated to the stream. - */ - public EsInfo(int streamType, String language, byte[] descriptorBytes) { - this.streamType = streamType; - this.language = language; - this.descriptorBytes = descriptorBytes; - } - - } - - /** - * Generates track ids for initializing {@link ElementaryStreamReader}s' {@link TrackOutput}s. - */ - public static final class TrackIdGenerator { - - private final int firstId; - private final int idIncrement; - private int generatedIdCount; - - public TrackIdGenerator(int firstId, int idIncrement) { - this.firstId = firstId; - this.idIncrement = idIncrement; - } - - public int getNextId() { - return firstId + idIncrement * generatedIdCount++; - } - - } +public interface ElementaryStreamReader { /** * Notifies the reader that a seek has occurred. */ - public abstract void seek(); + void seek(); /** * Initializes the reader by providing outputs and ids for the tracks. * * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. - * @param idGenerator A {@link TrackIdGenerator} that generates unique track ids for the + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the * {@link TrackOutput}s. */ - public abstract void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator); + void createTracks(ExtractorOutput extractorOutput, PesReader.TrackIdGenerator idGenerator); /** * Called when a packet starts. @@ -105,18 +44,18 @@ public abstract class ElementaryStreamReader { * @param pesTimeUs The timestamp associated with the packet. * @param dataAlignmentIndicator The data alignment indicator associated with the packet. */ - public abstract void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator); + void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator); /** * Consumes (possibly partial) data from the current packet. * * @param data The data to consume. */ - public abstract void consume(ParsableByteArray data); + void consume(ParsableByteArray data); /** * Called when a packet ends. */ - public abstract void packetFinished(); + void packetFinished(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java index fbfe7e1209..02ea6d7c4e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -29,7 +30,7 @@ import java.util.Collections; /** * Parses a continuous H262 byte stream and extracts individual frames. */ -/* package */ final class H262Reader extends ElementaryStreamReader { +/* package */ final class H262Reader implements ElementaryStreamReader { private static final int START_PICTURE = 0x00; private static final int START_SEQUENCE_HEADER = 0xB3; @@ -76,7 +77,7 @@ import java.util.Collections; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java index 6fee9ea6d7..ed4682d9b9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.NalUnitUtil.SpsData; @@ -32,7 +33,7 @@ import java.util.List; /** * Parses a continuous H264 byte stream and extracts individual frames. */ -/* package */ final class H264Reader extends ElementaryStreamReader { +/* package */ final class H264Reader implements ElementaryStreamReader { private static final int NAL_UNIT_TYPE_SEI = 6; // Supplemental enhancement information private static final int NAL_UNIT_TYPE_SPS = 7; // Sequence parameter set @@ -86,7 +87,7 @@ import java.util.List; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); sampleReader = new SampleReader(output, allowNonIdrKeyframes, detectAccessUnits); seiReader = new SeiReader(extractorOutput.track(idGenerator.getNextId())); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index 57d7e77bb7..a78169a054 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -29,7 +30,7 @@ import java.util.Collections; /** * Parses a continuous H.265 byte stream and extracts individual frames. */ -/* package */ final class H265Reader extends ElementaryStreamReader { +/* package */ final class H265Reader implements ElementaryStreamReader { private static final String TAG = "H265Reader"; @@ -88,7 +89,7 @@ import java.util.Collections; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); sampleReader = new SampleReader(output); seiReader = new SeiReader(extractorOutput.track(idGenerator.getNextId())); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java index 2c657d4aca..c19bc9d14e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java @@ -15,17 +15,21 @@ */ package com.google.android.exoplayer2.extractor.ts; +import android.util.Log; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; /** * Parses ID3 data and extracts individual text information frames. */ -/* package */ final class Id3Reader extends ElementaryStreamReader { +/* package */ final class Id3Reader implements ElementaryStreamReader { + + private static final String TAG = "Id3Reader"; private static final int ID3_HEADER_SIZE = 10; @@ -51,7 +55,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); output.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_ID3, null, Format.NO_VALUE, null)); @@ -81,7 +85,14 @@ import com.google.android.exoplayer2.util.ParsableByteArray; headerBytesAvailable); if (sampleBytesRead + headerBytesAvailable == ID3_HEADER_SIZE) { // We've finished reading the ID3 header. Extract the sample size. - id3Header.setPosition(6); // 'ID3' (3) + version (2) + flags (1) + id3Header.setPosition(0); + if ('I' != id3Header.readUnsignedByte() || 'D' != id3Header.readUnsignedByte() + || '3' != id3Header.readUnsignedByte()) { + Log.w(TAG, "Discarding invalid ID3 tag"); + writingSample = false; + return; + } + id3Header.skipBytes(3); // version (2) + flags (1) sampleSize = ID3_HEADER_SIZE + id3Header.readSynchSafeInt(); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java index d25d0703ae..c67e7ad0ab 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java @@ -20,12 +20,13 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.MpegAudioHeader; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableByteArray; /** * Parses a continuous MPEG Audio byte stream and extracts individual frames. */ -/* package */ final class MpegAudioReader extends ElementaryStreamReader { +/* package */ final class MpegAudioReader implements ElementaryStreamReader { private static final int STATE_FINDING_HEADER = 0; private static final int STATE_READING_HEADER = 1; @@ -74,7 +75,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; } @Override - public void init(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { output = extractorOutput.track(idGenerator.getNextId()); } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java new file mode 100644 index 0000000000..598394a870 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import android.util.Log; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses PES packet data and extracts samples. + */ +public final class PesReader implements TsPayloadReader { + + private static final String TAG = "PesReader"; + + private static final int STATE_FINDING_HEADER = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_HEADER_EXTENSION = 2; + private static final int STATE_READING_BODY = 3; + + private static final int HEADER_SIZE = 9; + private static final int MAX_HEADER_EXTENSION_SIZE = 10; + private static final int PES_SCRATCH_SIZE = 10; // max(HEADER_SIZE, MAX_HEADER_EXTENSION_SIZE) + + private final ElementaryStreamReader reader; + private final ParsableBitArray pesScratch; + + private int state; + private int bytesRead; + + private TimestampAdjuster timestampAdjuster; + private boolean ptsFlag; + private boolean dtsFlag; + private boolean seenFirstDts; + private int extendedHeaderLength; + private int payloadSize; + private boolean dataAlignmentIndicator; + private long timeUs; + + public PesReader(ElementaryStreamReader reader) { + this.reader = reader; + pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); + state = STATE_FINDING_HEADER; + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + this.timestampAdjuster = timestampAdjuster; + reader.createTracks(extractorOutput, idGenerator); + } + + // TsPayloadReader implementation. + + @Override + public final void seek() { + state = STATE_FINDING_HEADER; + bytesRead = 0; + seenFirstDts = false; + reader.seek(); + } + + @Override + public final void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + if (payloadUnitStartIndicator) { + switch (state) { + case STATE_FINDING_HEADER: + case STATE_READING_HEADER: + // Expected. + break; + case STATE_READING_HEADER_EXTENSION: + Log.w(TAG, "Unexpected start indicator reading extended header"); + break; + case STATE_READING_BODY: + // If payloadSize == -1 then the length of the previous packet was unspecified, and so + // we only know that it's finished now that we've seen the start of the next one. This + // is expected. If payloadSize != -1, then the length of the previous packet was known, + // but we didn't receive that amount of data. This is not expected. + if (payloadSize != -1) { + Log.w(TAG, "Unexpected start indicator: expected " + payloadSize + " more bytes"); + } + // Either way, notify the reader that it has now finished. + reader.packetFinished(); + break; + } + setState(STATE_READING_HEADER); + } + + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_HEADER: + data.skipBytes(data.bytesLeft()); + break; + case STATE_READING_HEADER: + if (continueRead(data, pesScratch.data, HEADER_SIZE)) { + setState(parseHeader() ? STATE_READING_HEADER_EXTENSION : STATE_FINDING_HEADER); + } + break; + case STATE_READING_HEADER_EXTENSION: + int readLength = Math.min(MAX_HEADER_EXTENSION_SIZE, extendedHeaderLength); + // Read as much of the extended header as we're interested in, and skip the rest. + if (continueRead(data, pesScratch.data, readLength) + && continueRead(data, null, extendedHeaderLength)) { + parseHeaderExtension(); + reader.packetStarted(timeUs, dataAlignmentIndicator); + setState(STATE_READING_BODY); + } + break; + case STATE_READING_BODY: + readLength = data.bytesLeft(); + int padding = payloadSize == -1 ? 0 : readLength - payloadSize; + if (padding > 0) { + readLength -= padding; + data.setLimit(data.getPosition() + readLength); + } + reader.consume(data); + if (payloadSize != -1) { + payloadSize -= readLength; + if (payloadSize == 0) { + reader.packetFinished(); + setState(STATE_READING_HEADER); + } + } + break; + } + } + } + + private void setState(int state) { + this.state = state; + bytesRead = 0; + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read, or {@code null} to skip. + * @param targetLength The target length of the read. + * @return Whether the target length has been reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + if (bytesToRead <= 0) { + return true; + } else if (target == null) { + source.skipBytes(bytesToRead); + } else { + source.readBytes(target, bytesRead, bytesToRead); + } + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + private boolean parseHeader() { + // Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of + // the header. + pesScratch.setPosition(0); + int startCodePrefix = pesScratch.readBits(24); + if (startCodePrefix != 0x000001) { + Log.w(TAG, "Unexpected start code prefix: " + startCodePrefix); + payloadSize = -1; + return false; + } + + pesScratch.skipBits(8); // stream_id. + int packetLength = pesScratch.readBits(16); + pesScratch.skipBits(5); // '10' (2), PES_scrambling_control (2), PES_priority (1) + dataAlignmentIndicator = pesScratch.readBit(); + pesScratch.skipBits(2); // copyright (1), original_or_copy (1) + ptsFlag = pesScratch.readBit(); + dtsFlag = pesScratch.readBit(); + // ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1), + // additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1) + pesScratch.skipBits(6); + extendedHeaderLength = pesScratch.readBits(8); + + if (packetLength == 0) { + payloadSize = -1; + } else { + payloadSize = packetLength + 6 /* packetLength does not include the first 6 bytes */ + - HEADER_SIZE - extendedHeaderLength; + } + return true; + } + + private void parseHeaderExtension() { + pesScratch.setPosition(0); + timeUs = C.TIME_UNSET; + if (ptsFlag) { + pesScratch.skipBits(4); // '0010' or '0011' + long pts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + if (!seenFirstDts && dtsFlag) { + pesScratch.skipBits(4); // '0011' + long dts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + // Subsequent PES packets may have earlier presentation timestamps than this one, but they + // should all be greater than or equal to this packet's decode timestamp. We feed the + // decode timestamp to the adjuster here so that in the case that this is the first to be + // fed, the adjuster will be able to compute an offset to apply such that the adjusted + // presentation timestamps of all future packets are non-negative. + timestampAdjuster.adjustTsTimestamp(dts); + seenFirstDts = true; + } + timeUs = timestampAdjuster.adjustTsTimestamp(pts); + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java index b615a3e8ee..5c50ca7bf3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -24,7 +24,7 @@ import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TimestampAdjuster; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.TrackIdGenerator; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; @@ -127,7 +127,7 @@ public final class PsExtractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { timestampAdjuster.reset(); for (int i = 0; i < psPayloadReaders.size(); i++) { psPayloadReaders.valueAt(i).seek(); @@ -202,7 +202,7 @@ public final class PsExtractor implements Extractor { } if (elementaryStreamReader != null) { TrackIdGenerator idGenerator = new TrackIdGenerator(streamId, MAX_STREAM_ID_PLUS_ONE); - elementaryStreamReader.init(output, idGenerator); + elementaryStreamReader.createTracks(output, idGenerator); payloadReader = new PesReader(elementaryStreamReader, timestampAdjuster); psPayloadReaders.put(streamId, payloadReader); } @@ -253,8 +253,7 @@ public final class PsExtractor implements Extractor { private int extendedHeaderLength; private long timeUs; - public PesReader(ElementaryStreamReader pesPayloadReader, - TimestampAdjuster timestampAdjuster) { + public PesReader(ElementaryStreamReader pesPayloadReader, TimestampAdjuster timestampAdjuster) { this.pesPayloadReader = pesPayloadReader; this.timestampAdjuster = timestampAdjuster; pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java new file mode 100644 index 0000000000..347c401337 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Reads section data. + */ +public interface SectionPayloadReader { + + /** + * Initializes the section payload reader. + * + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the + * {@link TrackOutput}s. + */ + void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator); + + /** + * Called by a {@link SectionReader} when a full section is received. + * + * @param sectionData The data belonging to a section starting from the table_id. If + * section_syntax_indicator is set to '1', {@code sectionData} excludes the CRC_32 field. + * Otherwise, all bytes belonging to the table section are included. + */ + void consume(ParsableByteArray sectionData); + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java new file mode 100644 index 0000000000..822f5653c4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; + +/** + * Reads section data packets and feeds the whole sections to a given {@link SectionPayloadReader}. + * Useful information on PSI sections can be found in ISO/IEC 13818-1, section 2.4.4. + */ +public final class SectionReader implements TsPayloadReader { + + private static final int SECTION_HEADER_LENGTH = 3; + private static final int DEFAULT_SECTION_BUFFER_LENGTH = 32; + private static final int MAX_SECTION_LENGTH = 4098; + + private final SectionPayloadReader reader; + private final ParsableByteArray sectionData; + + private int totalSectionLength; + private int bytesRead; + private boolean sectionSyntaxIndicator; + private boolean waitingForPayloadStart; + + public SectionReader(SectionPayloadReader reader) { + this.reader = reader; + sectionData = new ParsableByteArray(DEFAULT_SECTION_BUFFER_LENGTH); + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + reader.init(timestampAdjuster, extractorOutput, idGenerator); + waitingForPayloadStart = true; + } + + @Override + public void seek() { + waitingForPayloadStart = true; + } + + @Override + public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + int payloadStartPosition = C.POSITION_UNSET; + if (payloadUnitStartIndicator) { + int payloadStartOffset = data.readUnsignedByte(); + payloadStartPosition = data.getPosition() + payloadStartOffset; + } + + if (waitingForPayloadStart) { + if (!payloadUnitStartIndicator) { + return; + } + waitingForPayloadStart = false; + data.setPosition(payloadStartPosition); + bytesRead = 0; + } + + while (data.bytesLeft() > 0) { + if (bytesRead < SECTION_HEADER_LENGTH) { + // Note: see ISO/IEC 13818-1, section 2.4.4.3 for detailed information on the format of + // the header. + if (bytesRead == 0) { + int tableId = data.readUnsignedByte(); + data.setPosition(data.getPosition() - 1); + if (tableId == 0xFF /* forbidden value */) { + // No more sections in this ts packet. + waitingForPayloadStart = true; + return; + } + } + int headerBytesToRead = Math.min(data.bytesLeft(), SECTION_HEADER_LENGTH - bytesRead); + data.readBytes(sectionData.data, bytesRead, headerBytesToRead); + bytesRead += headerBytesToRead; + if (bytesRead == SECTION_HEADER_LENGTH) { + sectionData.reset(SECTION_HEADER_LENGTH); + sectionData.skipBytes(1); // Skip table id (8). + int secondHeaderByte = sectionData.readUnsignedByte(); + int thirdHeaderByte = sectionData.readUnsignedByte(); + sectionSyntaxIndicator = (secondHeaderByte & 0x80) != 0; + totalSectionLength = + (((secondHeaderByte & 0x0F) << 8) | thirdHeaderByte) + SECTION_HEADER_LENGTH; + if (sectionData.capacity() < totalSectionLength) { + // Ensure there is enough space to keep the whole section. + byte[] bytes = sectionData.data; + sectionData.reset( + Math.min(MAX_SECTION_LENGTH, Math.max(totalSectionLength, bytes.length * 2))); + System.arraycopy(bytes, 0, sectionData.data, 0, SECTION_HEADER_LENGTH); + } + } + } else { + // Reading the body. + int bodyBytesToRead = Math.min(data.bytesLeft(), totalSectionLength - bytesRead); + data.readBytes(sectionData.data, bytesRead, bodyBytesToRead); + bytesRead += bodyBytesToRead; + if (bytesRead == totalSectionLength) { + if (sectionSyntaxIndicator) { + // This section has common syntax as defined in ISO/IEC 13818-1, section 2.4.4.11. + if (Util.crc(sectionData.data, 0, totalSectionLength, 0xFFFFFFFF) != 0) { + // The CRC is invalid so discard the section. + waitingForPayloadStart = true; + return; + } + sectionData.reset(totalSectionLength - 4); // Exclude the CRC_32 field. + } else { + // This is a private section with private defined syntax. + sectionData.reset(totalSectionLength); + } + reader.consume(sectionData); + bytesRead = 0; + } + } + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java index 4971c0c2b1..a2791bcaae 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java @@ -57,19 +57,13 @@ import com.google.android.exoplayer2.util.ParsableByteArray; seiBuffer.skipBytes(8); // Ignore first three bits: reserved (1) + process_cc_data_flag (1) + zero_bit (1). int ccCount = seiBuffer.readUnsignedByte() & 0x1F; + // Ignore em_data (1) seiBuffer.skipBytes(1); - int sampleBytes = 0; - for (int i = 0; i < ccCount; i++) { - int ccValidityAndType = seiBuffer.peekUnsignedByte() & 0x07; - // Check that validity == 1 and type == 0 (i.e. NTSC_CC_FIELD_1). - if (ccValidityAndType != 0x04) { - seiBuffer.skipBytes(3); - } else { - sampleBytes += 3; - output.sampleData(seiBuffer, 3); - } - } - output.sampleMetadata(pesTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleBytes, 0, null); + // Each data packet consists of 24 bits: marker bits (5) + cc_valid (1) + cc_type (2) + // + cc_data_1 (8) + cc_data_2 (8). + int sampleLength = ccCount * 3; + output.sampleData(seiBuffer, sampleLength); + output.sampleMetadata(pesTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleLength, 0, null); // Ignore trailing information in SEI, if any. seiBuffer.skipBytes(payloadSize - (10 + ccCount * 3)); } else { diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java new file mode 100644 index 0000000000..b1e71d6651 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses splice info sections as defined by SCTE35. + */ +public final class SpliceInfoSectionReader implements SectionPayloadReader { + + private TrackOutput output; + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TsPayloadReader.TrackIdGenerator idGenerator) { + output = extractorOutput.track(idGenerator.getNextId()); + output.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_SCTE35, null, + Format.NO_VALUE, null)); + } + + @Override + public void consume(ParsableByteArray sectionData) { + int sampleSize = sectionData.bytesLeft(); + output.sampleData(sectionData, sampleSize); + output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index bac362d711..bf5adac500 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.extractor.ts; -import android.util.Log; import android.util.SparseArray; import android.util.SparseBooleanArray; import android.util.SparseIntArray; @@ -28,8 +27,8 @@ import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TimestampAdjuster; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.EsInfo; -import com.google.android.exoplayer2.extractor.ts.ElementaryStreamReader.TrackIdGenerator; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -65,8 +64,7 @@ public final class TsExtractor implements Extractor { public static final int TS_STREAM_TYPE_H264 = 0x1B; public static final int TS_STREAM_TYPE_H265 = 0x24; public static final int TS_STREAM_TYPE_ID3 = 0x15; - - private static final String TAG = "TsExtractor"; + public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86; private static final int TS_PACKET_SIZE = 188; private static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet. @@ -80,19 +78,19 @@ public final class TsExtractor implements Extractor { private static final int BUFFER_PACKET_COUNT = 5; // Should be at least 2 private static final int BUFFER_SIZE = TS_PACKET_SIZE * BUFFER_PACKET_COUNT; - private final boolean mapByType; + private final boolean hlsMode; private final TimestampAdjuster timestampAdjuster; private final ParsableByteArray tsPacketBuffer; private final ParsableBitArray tsScratch; private final SparseIntArray continuityCounters; - private final ElementaryStreamReader.Factory streamReaderFactory; + private final TsPayloadReader.Factory payloadReaderFactory; private final SparseArray tsPayloadReaders; // Indexed by pid private final SparseBooleanArray trackIds; // Accessed only by the loading thread. private ExtractorOutput output; private boolean tracksEnded; - private ElementaryStreamReader id3Reader; + private TsPayloadReader id3Reader; public TsExtractor() { this(new TimestampAdjuster(0)); @@ -102,20 +100,20 @@ public final class TsExtractor implements Extractor { * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. */ public TsExtractor(TimestampAdjuster timestampAdjuster) { - this(timestampAdjuster, new DefaultStreamReaderFactory(), false); + this(timestampAdjuster, new DefaultTsPayloadReaderFactory(), false); } /** * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. - * @param customReaderFactory Factory for injecting a custom set of elementary stream readers. - * @param mapByType True if {@link TrackOutput}s should be mapped by their type, false to map them - * by their PID. + * @param payloadReaderFactory Factory for injecting a custom set of payload readers. + * @param hlsMode Whether the extractor should be used in HLS mode. If true, {@link TrackOutput}s + * are mapped by their type (instead of PID) and continuity counters are ignored. */ public TsExtractor(TimestampAdjuster timestampAdjuster, - ElementaryStreamReader.Factory customReaderFactory, boolean mapByType) { + TsPayloadReader.Factory payloadReaderFactory, boolean hlsMode) { this.timestampAdjuster = timestampAdjuster; - this.streamReaderFactory = Assertions.checkNotNull(customReaderFactory); - this.mapByType = mapByType; + this.payloadReaderFactory = Assertions.checkNotNull(payloadReaderFactory); + this.hlsMode = hlsMode; tsPacketBuffer = new ParsableByteArray(BUFFER_SIZE); tsScratch = new ParsableBitArray(new byte[3]); trackIds = new SparseBooleanArray(); @@ -151,7 +149,7 @@ public final class TsExtractor implements Extractor { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { timestampAdjuster.reset(); tsPacketBuffer.reset(); continuityCounters.clear(); @@ -213,16 +211,22 @@ public final class TsExtractor implements Extractor { tsScratch.skipBits(2); // transport_scrambling_control boolean adaptationFieldExists = tsScratch.readBit(); boolean payloadExists = tsScratch.readBit(); + + // Discontinuity check. boolean discontinuityFound = false; int continuityCounter = tsScratch.readBits(4); - int previousCounter = continuityCounters.get(pid, continuityCounter - 1); - continuityCounters.put(pid, continuityCounter); - if (previousCounter == continuityCounter) { - // Duplicate packet found. - tsPacketBuffer.setPosition(endOfPacket); - return RESULT_CONTINUE; - } else if (continuityCounter != (previousCounter + 1) % 16) { - discontinuityFound = true; + if (!hlsMode) { + int previousCounter = continuityCounters.get(pid, continuityCounter - 1); + continuityCounters.put(pid, continuityCounter); + if (previousCounter == continuityCounter) { + if (payloadExists) { + // Duplicate packet found. + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + } else if (continuityCounter != (previousCounter + 1) % 16) { + discontinuityFound = true; + } } // Skip the adaptation field. @@ -239,7 +243,7 @@ public final class TsExtractor implements Extractor { payloadReader.seek(); } tsPacketBuffer.setLimit(endOfPacket); - payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator, output); + payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator); Assertions.checkState(tsPacketBuffer.getPosition() <= endOfPacket); tsPacketBuffer.setLimit(limit); } @@ -254,95 +258,46 @@ public final class TsExtractor implements Extractor { private void resetPayloadReaders() { trackIds.clear(); tsPayloadReaders.clear(); - tsPayloadReaders.put(TS_PAT_PID, new PatReader()); + SparseArray initialPayloadReaders = + payloadReaderFactory.createInitialPayloadReaders(); + int initialPayloadReadersSize = initialPayloadReaders.size(); + for (int i = 0; i < initialPayloadReadersSize; i++) { + tsPayloadReaders.put(initialPayloadReaders.keyAt(i), initialPayloadReaders.valueAt(i)); + } + tsPayloadReaders.put(TS_PAT_PID, new SectionReader(new PatReader())); id3Reader = null; } - /** - * Parses TS packet payload data. - */ - private abstract static class TsPayloadReader { - - /** - * Notifies the reader that a seek has occurred. - *

- * Following a call to this method, the data passed to the next invocation of - * {@link #consume(ParsableByteArray, boolean, ExtractorOutput)} will not be a continuation of - * the data that was previously passed. Hence the reader should reset any internal state. - */ - public abstract void seek(); - - /** - * Consumes the payload of a TS packet. - * - * @param data The TS packet. The position will be set to the start of the payload. - * @param payloadUnitStartIndicator Whether payloadUnitStartIndicator was set on the TS packet. - * @param output The output to which parsed data should be written. - */ - public abstract void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, - ExtractorOutput output); - - } - /** * Parses Program Association Table data. */ - private class PatReader extends TsPayloadReader { + private class PatReader implements SectionPayloadReader { - private final ParsableByteArray sectionData; private final ParsableBitArray patScratch; - private int sectionLength; - private int sectionBytesRead; - private int crc; - public PatReader() { - sectionData = new ParsableByteArray(); patScratch = new ParsableBitArray(new byte[4]); } @Override - public void seek() { + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { // Do nothing. } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, - ExtractorOutput output) { - // Skip pointer. - if (payloadUnitStartIndicator) { - int pointerField = data.readUnsignedByte(); - data.skipBytes(pointerField); - - // Note: see ISO/IEC 13818-1, section 2.4.4.3 for detailed information on the format of - // the header. - data.readBytes(patScratch, 3); - patScratch.skipBits(12); // table_id (8), section_syntax_indicator (1), 0 (1), reserved (2) - sectionLength = patScratch.readBits(12); - sectionBytesRead = 0; - crc = Util.crc(patScratch.data, 0, 3, 0xFFFFFFFF); - - sectionData.reset(sectionLength); - } - - int bytesToRead = Math.min(data.bytesLeft(), sectionLength - sectionBytesRead); - data.readBytes(sectionData.data, sectionBytesRead, bytesToRead); - sectionBytesRead += bytesToRead; - if (sectionBytesRead < sectionLength) { - // Not yet fully read. + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x00 /* program_association_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. return; } - - if (Util.crc(sectionData.data, 0, sectionLength, crc) != 0) { - // CRC Invalid. The section gets discarded. - return; - } - + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), // transport_stream_id (16), reserved (2), version_number (5), current_next_indicator (1), // section_number (8), last_section_number (8) - sectionData.skipBytes(5); + sectionData.skipBytes(7); - int programCount = (sectionLength - 9) / 4; + int programCount = sectionData.bytesLeft() / 4; for (int i = 0; i < programCount; i++) { sectionData.readBytes(patScratch, 4); int programNumber = patScratch.readBits(16); @@ -351,7 +306,7 @@ public final class TsExtractor implements Extractor { patScratch.skipBits(13); // network_PID (13) } else { int pid = patScratch.readBits(13); - tsPayloadReaders.put(pid, new PmtReader(pid)); + tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid))); } } } @@ -361,7 +316,7 @@ public final class TsExtractor implements Extractor { /** * Parses Program Map Table. */ - private class PmtReader extends TsPayloadReader { + private class PmtReader implements SectionPayloadReader { private static final int TS_PMT_DESC_REGISTRATION = 0x05; private static final int TS_PMT_DESC_ISO639_LANG = 0x0A; @@ -370,60 +325,30 @@ public final class TsExtractor implements Extractor { private static final int TS_PMT_DESC_DTS = 0x7B; private final ParsableBitArray pmtScratch; - private final ParsableByteArray sectionData; private final int pid; - private int sectionLength; - private int sectionBytesRead; - private int crc; - public PmtReader(int pid) { pmtScratch = new ParsableBitArray(new byte[5]); - sectionData = new ParsableByteArray(); this.pid = pid; } @Override - public void seek() { + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { // Do nothing. } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, - ExtractorOutput output) { - if (payloadUnitStartIndicator) { - // Skip pointer. - int pointerField = data.readUnsignedByte(); - data.skipBytes(pointerField); - - // Note: see ISO/IEC 13818-1, section 2.4.4.8 for detailed information on the format of - // the header. - data.readBytes(pmtScratch, 3); - pmtScratch.skipBits(12); // table_id (8), section_syntax_indicator (1), 0 (1), reserved (2) - sectionLength = pmtScratch.readBits(12); - sectionBytesRead = 0; - crc = Util.crc(pmtScratch.data, 0, 3, 0xFFFFFFFF); - - sectionData.reset(sectionLength); - } - - int bytesToRead = Math.min(data.bytesLeft(), sectionLength - sectionBytesRead); - data.readBytes(sectionData.data, sectionBytesRead, bytesToRead); - sectionBytesRead += bytesToRead; - if (sectionBytesRead < sectionLength) { - // Not yet fully read. + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x02 /* TS_program_map_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. return; } - - if (Util.crc(sectionData.data, 0, sectionLength, crc) != 0) { - // CRC Invalid. The section gets discarded. - return; - } - - // program_number (16), reserved (2), version_number (5), current_next_indicator (1), - // section_number (8), last_section_number (8), reserved (3), PCR_PID (13) - // Skip the rest of the PMT header. - sectionData.skipBytes(7); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), program_number (16), + // reserved (2), version_number (5), current_next_indicator (1), // section_number (8), + // last_section_number (8), reserved (3), PCR_PID (13) + sectionData.skipBytes(9); // Read program_info_length. sectionData.readBytes(pmtScratch, 2); @@ -433,16 +358,16 @@ public final class TsExtractor implements Extractor { // Skip the descriptors. sectionData.skipBytes(programInfoLength); - if (mapByType && id3Reader == null) { + if (hlsMode && id3Reader == null) { // Setup an ID3 track regardless of whether there's a corresponding entry, in case one // appears intermittently during playback. See [Internal: b/20261500]. EsInfo dummyEsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, new byte[0]); - id3Reader = streamReaderFactory.createStreamReader(TS_STREAM_TYPE_ID3, dummyEsInfo); - id3Reader.init(output, new TrackIdGenerator(TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); + id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, dummyEsInfo); + id3Reader.init(timestampAdjuster, output, + new TrackIdGenerator(TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); } - int remainingEntriesLength = sectionLength - 9 /* Length of fields before descriptors */ - - programInfoLength - 4 /* CRC length */; + int remainingEntriesLength = sectionData.bytesLeft(); while (remainingEntriesLength > 0) { sectionData.readBytes(pmtScratch, 5); int streamType = pmtScratch.readBits(8); @@ -456,25 +381,27 @@ public final class TsExtractor implements Extractor { } remainingEntriesLength -= esInfoLength + 5; - int trackId = mapByType ? streamType : elementaryPid; + int trackId = hlsMode ? streamType : elementaryPid; if (trackIds.get(trackId)) { continue; } trackIds.put(trackId, true); - ElementaryStreamReader pesPayloadReader; - if (mapByType && streamType == TS_STREAM_TYPE_ID3) { - pesPayloadReader = id3Reader; + TsPayloadReader reader; + if (hlsMode && streamType == TS_STREAM_TYPE_ID3) { + reader = id3Reader; } else { - pesPayloadReader = streamReaderFactory.createStreamReader(streamType, esInfo); - pesPayloadReader.init(output, new TrackIdGenerator(trackId, MAX_PID_PLUS_ONE)); + reader = payloadReaderFactory.createPayloadReader(streamType, esInfo); + if (reader != null) { + reader.init(timestampAdjuster, output, new TrackIdGenerator(trackId, MAX_PID_PLUS_ONE)); + } } - if (pesPayloadReader != null) { - tsPayloadReaders.put(elementaryPid, new PesReader(pesPayloadReader, timestampAdjuster)); + if (reader != null) { + tsPayloadReaders.put(elementaryPid, reader); } } - if (mapByType) { + if (hlsMode) { if (!tracksEnded) { output.endTracks(); } @@ -527,213 +454,10 @@ public final class TsExtractor implements Extractor { } data.setPosition(descriptorsEndPosition); return new EsInfo(streamType, language, - Arrays.copyOfRange(sectionData.data, descriptorsStartPosition, descriptorsEndPosition)); + Arrays.copyOfRange(data.data, descriptorsStartPosition, descriptorsEndPosition)); } } - /** - * Parses PES packet data and extracts samples. - */ - private static final class PesReader extends TsPayloadReader { - - private static final int STATE_FINDING_HEADER = 0; - private static final int STATE_READING_HEADER = 1; - private static final int STATE_READING_HEADER_EXTENSION = 2; - private static final int STATE_READING_BODY = 3; - - private static final int HEADER_SIZE = 9; - private static final int MAX_HEADER_EXTENSION_SIZE = 10; - private static final int PES_SCRATCH_SIZE = 10; // max(HEADER_SIZE, MAX_HEADER_EXTENSION_SIZE) - - private final ElementaryStreamReader pesPayloadReader; - private final TimestampAdjuster timestampAdjuster; - private final ParsableBitArray pesScratch; - - private int state; - private int bytesRead; - - private boolean ptsFlag; - private boolean dtsFlag; - private boolean seenFirstDts; - private int extendedHeaderLength; - private int payloadSize; - private boolean dataAlignmentIndicator; - private long timeUs; - - public PesReader(ElementaryStreamReader pesPayloadReader, - TimestampAdjuster timestampAdjuster) { - this.pesPayloadReader = pesPayloadReader; - this.timestampAdjuster = timestampAdjuster; - pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); - state = STATE_FINDING_HEADER; - } - - @Override - public void seek() { - state = STATE_FINDING_HEADER; - bytesRead = 0; - seenFirstDts = false; - pesPayloadReader.seek(); - } - - @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, - ExtractorOutput output) { - if (payloadUnitStartIndicator) { - switch (state) { - case STATE_FINDING_HEADER: - case STATE_READING_HEADER: - // Expected. - break; - case STATE_READING_HEADER_EXTENSION: - Log.w(TAG, "Unexpected start indicator reading extended header"); - break; - case STATE_READING_BODY: - // If payloadSize == -1 then the length of the previous packet was unspecified, and so - // we only know that it's finished now that we've seen the start of the next one. This - // is expected. If payloadSize != -1, then the length of the previous packet was known, - // but we didn't receive that amount of data. This is not expected. - if (payloadSize != -1) { - Log.w(TAG, "Unexpected start indicator: expected " + payloadSize + " more bytes"); - } - // Either way, notify the reader that it has now finished. - pesPayloadReader.packetFinished(); - break; - } - setState(STATE_READING_HEADER); - } - - while (data.bytesLeft() > 0) { - switch (state) { - case STATE_FINDING_HEADER: - data.skipBytes(data.bytesLeft()); - break; - case STATE_READING_HEADER: - if (continueRead(data, pesScratch.data, HEADER_SIZE)) { - setState(parseHeader() ? STATE_READING_HEADER_EXTENSION : STATE_FINDING_HEADER); - } - break; - case STATE_READING_HEADER_EXTENSION: - int readLength = Math.min(MAX_HEADER_EXTENSION_SIZE, extendedHeaderLength); - // Read as much of the extended header as we're interested in, and skip the rest. - if (continueRead(data, pesScratch.data, readLength) - && continueRead(data, null, extendedHeaderLength)) { - parseHeaderExtension(); - pesPayloadReader.packetStarted(timeUs, dataAlignmentIndicator); - setState(STATE_READING_BODY); - } - break; - case STATE_READING_BODY: - readLength = data.bytesLeft(); - int padding = payloadSize == -1 ? 0 : readLength - payloadSize; - if (padding > 0) { - readLength -= padding; - data.setLimit(data.getPosition() + readLength); - } - pesPayloadReader.consume(data); - if (payloadSize != -1) { - payloadSize -= readLength; - if (payloadSize == 0) { - pesPayloadReader.packetFinished(); - setState(STATE_READING_HEADER); - } - } - break; - } - } - } - - private void setState(int state) { - this.state = state; - bytesRead = 0; - } - - /** - * Continues a read from the provided {@code source} into a given {@code target}. It's assumed - * that the data should be written into {@code target} starting from an offset of zero. - * - * @param source The source from which to read. - * @param target The target into which data is to be read, or {@code null} to skip. - * @param targetLength The target length of the read. - * @return Whether the target length has been reached. - */ - private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); - if (bytesToRead <= 0) { - return true; - } else if (target == null) { - source.skipBytes(bytesToRead); - } else { - source.readBytes(target, bytesRead, bytesToRead); - } - bytesRead += bytesToRead; - return bytesRead == targetLength; - } - - private boolean parseHeader() { - // Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of - // the header. - pesScratch.setPosition(0); - int startCodePrefix = pesScratch.readBits(24); - if (startCodePrefix != 0x000001) { - Log.w(TAG, "Unexpected start code prefix: " + startCodePrefix); - payloadSize = -1; - return false; - } - - pesScratch.skipBits(8); // stream_id. - int packetLength = pesScratch.readBits(16); - pesScratch.skipBits(5); // '10' (2), PES_scrambling_control (2), PES_priority (1) - dataAlignmentIndicator = pesScratch.readBit(); - pesScratch.skipBits(2); // copyright (1), original_or_copy (1) - ptsFlag = pesScratch.readBit(); - dtsFlag = pesScratch.readBit(); - // ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1), - // additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1) - pesScratch.skipBits(6); - extendedHeaderLength = pesScratch.readBits(8); - - if (packetLength == 0) { - payloadSize = -1; - } else { - payloadSize = packetLength + 6 /* packetLength does not include the first 6 bytes */ - - HEADER_SIZE - extendedHeaderLength; - } - return true; - } - - private void parseHeaderExtension() { - pesScratch.setPosition(0); - timeUs = C.TIME_UNSET; - if (ptsFlag) { - pesScratch.skipBits(4); // '0010' or '0011' - long pts = (long) pesScratch.readBits(3) << 30; - pesScratch.skipBits(1); // marker_bit - pts |= pesScratch.readBits(15) << 15; - pesScratch.skipBits(1); // marker_bit - pts |= pesScratch.readBits(15); - pesScratch.skipBits(1); // marker_bit - if (!seenFirstDts && dtsFlag) { - pesScratch.skipBits(4); // '0011' - long dts = (long) pesScratch.readBits(3) << 30; - pesScratch.skipBits(1); // marker_bit - dts |= pesScratch.readBits(15) << 15; - pesScratch.skipBits(1); // marker_bit - dts |= pesScratch.readBits(15); - pesScratch.skipBits(1); // marker_bit - // Subsequent PES packets may have earlier presentation timestamps than this one, but they - // should all be greater than or equal to this packet's decode timestamp. We feed the - // decode timestamp to the adjuster here so that in the case that this is the first to be - // fed, the adjuster will be able to compute an offset to apply such that the adjusted - // presentation timestamps of all future packets are non-negative. - timestampAdjuster.adjustTsTimestamp(dts); - seenFirstDts = true; - } - timeUs = timestampAdjuster.adjustTsTimestamp(pts); - } - } - - } } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java new file mode 100644 index 0000000000..304c8c1282 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import android.util.SparseArray; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses TS packet payload data. + */ +public interface TsPayloadReader { + + /** + * Factory of {@link TsPayloadReader} instances. + */ + interface Factory { + + /** + * Returns the initial mapping from PIDs to payload readers. + *

+ * This method allows the injection of payload readers for reserved PIDs, excluding PID 0. + * + * @return A {@link SparseArray} that maps PIDs to payload readers. + */ + SparseArray createInitialPayloadReaders(); + + /** + * Returns a {@link TsPayloadReader} for a given stream type and elementary stream information. + * May return null if the stream type is not supported. + * + * @param streamType Stream type value as defined in the PMT entry or associated descriptors. + * @param esInfo Information associated to the elementary stream provided in the PMT. + * @return A {@link TsPayloadReader} for the packet stream carried by the provided pid. + * {@code null} if the stream is not supported. + */ + TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo); + + } + + /** + * Holds information associated with a PMT entry. + */ + final class EsInfo { + + public final int streamType; + public final String language; + public final byte[] descriptorBytes; + + /** + * @param streamType The type of the stream as defined by the + * {@link TsExtractor}{@code .TS_STREAM_TYPE_*}. + * @param language The language of the stream, as defined by ISO/IEC 13818-1, section 2.6.18. + * @param descriptorBytes The descriptor bytes associated to the stream. + */ + public EsInfo(int streamType, String language, byte[] descriptorBytes) { + this.streamType = streamType; + this.language = language; + this.descriptorBytes = descriptorBytes; + } + + } + + /** + * Generates track ids for initializing {@link TsPayloadReader}s' {@link TrackOutput}s. + */ + final class TrackIdGenerator { + + private final int firstId; + private final int idIncrement; + private int generatedIdCount; + + public TrackIdGenerator(int firstId, int idIncrement) { + this.firstId = firstId; + this.idIncrement = idIncrement; + } + + public int getNextId() { + return firstId + idIncrement * generatedIdCount++; + } + + } + + /** + * Initializes the payload reader. + * + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the + * {@link TrackOutput}s. + */ + void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator); + + /** + * Notifies the reader that a seek has occurred. + *

+ * Following a call to this method, the data passed to the next invocation of + * {@link #consume(ParsableByteArray, boolean)} will not be a continuation of the data that was + * previously passed. Hence the reader should reset any internal state. + */ + void seek(); + + /** + * Consumes the payload of a TS packet. + * + * @param data The TS packet. The position will be set to the start of the payload. + * @param payloadUnitStartIndicator Whether payloadUnitStartIndicator was set on the TS packet. + */ + void consume(ParsableByteArray data, boolean payloadUnitStartIndicator); + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java index 017a26f0af..3d9f8166ab 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java @@ -66,7 +66,7 @@ public final class WavExtractor implements Extractor, SeekMap { } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { pendingBytes = 0; } diff --git a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 51c23172a7..166de37c50 100644 --- a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java +++ b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -21,6 +21,7 @@ import android.media.MediaCodecInfo.AudioCapabilities; import android.media.MediaCodecInfo.CodecCapabilities; import android.media.MediaCodecInfo.CodecProfileLevel; import android.media.MediaCodecInfo.VideoCapabilities; +import android.util.Log; import android.util.Pair; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; @@ -32,6 +33,8 @@ import com.google.android.exoplayer2.util.Util; @TargetApi(16) public final class MediaCodecInfo { + public static final String TAG = "MediaCodecInfo"; + /** * The name of the decoder. *

@@ -48,6 +51,14 @@ public final class MediaCodecInfo { */ public final boolean adaptive; + /** + * Whether the decoder supports tunneling. + * + * @see CodecCapabilities#isFeatureSupported(String) + * @see CodecCapabilities#FEATURE_TunneledPlayback + */ + public final boolean tunneling; + private final String mimeType; private final CodecCapabilities capabilities; @@ -83,6 +94,7 @@ public final class MediaCodecInfo { this.mimeType = mimeType; this.capabilities = capabilities; adaptive = capabilities != null && isAdaptive(capabilities); + tunneling = capabilities != null && isTunneling(capabilities); } /** @@ -111,6 +123,7 @@ public final class MediaCodecInfo { return true; } if (!mimeType.equals(codecMimeType)) { + logNoSupport("codec.mime " + codec + ", " + codecMimeType); return false; } Pair codecProfileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(codec); @@ -124,6 +137,7 @@ public final class MediaCodecInfo { return true; } } + logNoSupport("codec.profileLevel, " + codec + ", " + codecMimeType); return false; } @@ -139,10 +153,25 @@ public final class MediaCodecInfo { @TargetApi(21) public boolean isVideoSizeSupportedV21(int width, int height) { if (capabilities == null) { + logNoSupport("size.caps"); return false; } VideoCapabilities videoCapabilities = capabilities.getVideoCapabilities(); - return videoCapabilities != null && videoCapabilities.isSizeSupported(width, height); + if (videoCapabilities == null) { + logNoSupport("size.vCaps"); + return false; + } + if (!videoCapabilities.isSizeSupported(width, height)) { + // Capabilities are known to be inaccurately reported for vertical resolutions on some devices + // (b/31387661). If the video is vertical and the capabilities indicate support if the width + // and height are swapped, we assume that the vertical resolution is also supported. + if (width >= height || !videoCapabilities.isSizeSupported(height, width)) { + logNoSupport("size.support, " + width + "x" + height); + return false; + } + logAssumedSupport("size.rotated, " + width + "x" + height); + } + return true; } /** @@ -158,11 +187,25 @@ public final class MediaCodecInfo { @TargetApi(21) public boolean isVideoSizeAndRateSupportedV21(int width, int height, double frameRate) { if (capabilities == null) { + logNoSupport("sizeAndRate.caps"); return false; } VideoCapabilities videoCapabilities = capabilities.getVideoCapabilities(); - return videoCapabilities != null && videoCapabilities.areSizeAndRateSupported(width, height, - frameRate); + if (videoCapabilities == null) { + logNoSupport("sizeAndRate.vCaps"); + return false; + } + if (!videoCapabilities.areSizeAndRateSupported(width, height, frameRate)) { + // Capabilities are known to be inaccurately reported for vertical resolutions on some devices + // (b/31387661). If the video is vertical and the capabilities indicate support if the width + // and height are swapped, we assume that the vertical resolution is also supported. + if (width >= height || !videoCapabilities.areSizeAndRateSupported(height, width, frameRate)) { + logNoSupport("sizeAndRate.support, " + width + "x" + height + "x" + frameRate); + return false; + } + logAssumedSupport("sizeAndRate.rotated, " + width + "x" + height + "x" + frameRate); + } + return true; } /** @@ -176,10 +219,19 @@ public final class MediaCodecInfo { @TargetApi(21) public boolean isAudioSampleRateSupportedV21(int sampleRate) { if (capabilities == null) { + logNoSupport("sampleRate.caps"); return false; } AudioCapabilities audioCapabilities = capabilities.getAudioCapabilities(); - return audioCapabilities != null && audioCapabilities.isSampleRateSupported(sampleRate); + if (audioCapabilities == null) { + logNoSupport("sampleRate.aCaps"); + return false; + } + if (!audioCapabilities.isSampleRateSupported(sampleRate)) { + logNoSupport("sampleRate.support, " + sampleRate); + return false; + } + return true; } /** @@ -193,10 +245,29 @@ public final class MediaCodecInfo { @TargetApi(21) public boolean isAudioChannelCountSupportedV21(int channelCount) { if (capabilities == null) { + logNoSupport("channelCount.caps"); return false; } AudioCapabilities audioCapabilities = capabilities.getAudioCapabilities(); - return audioCapabilities != null && audioCapabilities.getMaxInputChannelCount() >= channelCount; + if (audioCapabilities == null) { + logNoSupport("channelCount.aCaps"); + return false; + } + if (audioCapabilities.getMaxInputChannelCount() < channelCount) { + logNoSupport("channelCount.support, " + channelCount); + return false; + } + return true; + } + + private void logNoSupport(String message) { + Log.d(TAG, "NoSupport [" + message + "] [" + name + ", " + mimeType + "] [" + + Util.DEVICE_DEBUG_INFO + "]"); + } + + private void logAssumedSupport(String message) { + Log.d(TAG, "AssumedSupport [" + message + "] [" + name + ", " + mimeType + "] [" + + Util.DEVICE_DEBUG_INFO + "]"); } private static boolean isAdaptive(CodecCapabilities capabilities) { @@ -208,4 +279,13 @@ public final class MediaCodecInfo { return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback); } + private static boolean isTunneling(CodecCapabilities capabilities) { + return Util.SDK_INT >= 21 && isTunnelingV21(capabilities); + } + + @TargetApi(21) + private static boolean isTunnelingV21(CodecCapabilities capabilities) { + return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 63a77e2215..6dce2abc2a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -270,7 +270,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { */ protected MediaCodecInfo getDecoderInfo(MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder) throws DecoderQueryException { - return mediaCodecSelector.getDecoderInfo(format.sampleMimeType, requiresSecureDecoder); + return mediaCodecSelector.getDecoderInfo(format.sampleMimeType, requiresSecureDecoder, false); } /** @@ -375,6 +375,10 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return codec == null && format != null; } + protected final MediaCodec getCodec() { + return codec; + } + @Override protected void onEnabled(boolean joining) throws ExoPlaybackException { decoderCounters = new DecoderCounters(); @@ -468,6 +472,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (outputStreamEnded) { + return; + } if (format == null) { readFormat(); } @@ -525,10 +532,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * @throws ExoPlaybackException If an error occurs feeding the input buffer. */ private boolean feedInputBuffer() throws ExoPlaybackException { - if (inputStreamEnded - || codecReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { - // The input stream has ended, or we need to re-initialize the codec but are still waiting - // for the existing codec to output any final output buffers. + if (codec == null || codecReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM + || inputStreamEnded) { + // We need to reinitialize the codec or the input stream has ended. return false; } @@ -842,10 +848,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @SuppressWarnings("deprecation") private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - if (outputStreamEnded) { - return false; - } - if (outputIndex < 0) { outputIndex = codec.dequeueOutputBuffer(outputBufferInfo, getDequeueOutputBufferTimeoutUs()); if (outputIndex >= 0) { @@ -860,7 +862,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { // The dequeued buffer indicates the end of the stream. Process it immediately. processEndOfStream(); outputIndex = C.INDEX_UNSET; - return true; + return false; } else { // The dequeued buffer is a media buffer. Do some initial setup. The buffer will be // processed by calling processOutputBuffer (possibly multiple times) below. @@ -881,7 +883,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (codecNeedsEosPropagationWorkaround && (inputStreamEnded || codecReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM)) { processEndOfStream(); - return true; } return false; } @@ -1019,7 +1020,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return Util.SDK_INT < 24 && ("OMX.Nvidia.h264.decode".equals(name) || "OMX.Nvidia.h264.decode.secure".equals(name)) && ("flounder".equals(Util.DEVICE) || "flounder_lte".equals(Util.DEVICE) - || "grouper".equals(Util.DEVICE) || "tilapia".equals(Util.DEVICE)); + || "grouper".equals(Util.DEVICE) || "tilapia".equals(Util.DEVICE)); } /** @@ -1066,7 +1067,10 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * buffer with {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. False otherwise. */ private static boolean codecNeedsEosFlushWorkaround(String name) { - return Util.SDK_INT <= 23 && "OMX.google.vorbis.decoder".equals(name); + return (Util.SDK_INT <= 23 && "OMX.google.vorbis.decoder".equals(name)) + || (Util.SDK_INT <= 19 && "hb2000".equals(Util.DEVICE) + && ("OMX.amlogic.avc.decoder.awesome".equals(name) + || "OMX.amlogic.avc.decoder.awesome.secure".equals(name))); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java index 250faaa993..ea8832c39c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java +++ b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java @@ -29,9 +29,9 @@ public interface MediaCodecSelector { MediaCodecSelector DEFAULT = new MediaCodecSelector() { @Override - public MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder) - throws DecoderQueryException { - return MediaCodecUtil.getDecoderInfo(mimeType, requiresSecureDecoder); + public MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder, + boolean requiresTunneling) throws DecoderQueryException { + return MediaCodecUtil.getDecoderInfo(mimeType, requiresSecureDecoder, requiresTunneling); } @Override @@ -46,12 +46,13 @@ public interface MediaCodecSelector { * * @param mimeType The mime type for which a decoder is required. * @param requiresSecureDecoder Whether a secure decoder is required. + * @param requiresTunneling Whether a decoder that supports tunneling is required. * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder * exists. * @throws DecoderQueryException Thrown if there was an error querying decoders. */ - MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder) - throws DecoderQueryException; + MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder, + boolean requiresTunneling) throws DecoderQueryException; /** * Selects a decoder to instantiate for audio passthrough. diff --git a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index 2f9524b7f0..14ba309790 100644 --- a/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -23,6 +23,7 @@ import android.media.MediaCodecList; import android.text.TextUtils; import android.util.Log; import android.util.Pair; +import android.util.SparseIntArray; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; @@ -63,8 +64,8 @@ public final class MediaCodecUtil { // Codecs to constant mappings. // AVC. - private static final Map AVC_PROFILE_NUMBER_TO_CONST; - private static final Map AVC_LEVEL_NUMBER_TO_CONST; + private static final SparseIntArray AVC_PROFILE_NUMBER_TO_CONST; + private static final SparseIntArray AVC_LEVEL_NUMBER_TO_CONST; private static final String CODEC_ID_AVC1 = "avc1"; private static final String CODEC_ID_AVC2 = "avc2"; // HEVC. @@ -80,7 +81,9 @@ public final class MediaCodecUtil { /** * Optional call to warm the codec cache for a given mime type. *

- * Calling this method may speed up subsequent calls to {@link #getDecoderInfo(String, boolean)}. + * Calling this method may speed up subsequent calls to + * {@link #getDecoderInfo(String, boolean, boolean)} and + * {@link #getDecoderInfos(String, boolean)}. * * @param mimeType The mime type. * @param secure Whether the decoder is required to support secure decryption. Always pass false @@ -112,14 +115,26 @@ public final class MediaCodecUtil { * @param mimeType The mime type. * @param secure Whether the decoder is required to support secure decryption. Always pass false * unless secure decryption really is required. + * @param tunneling Whether the decoder is required to support tunneling. Always pass false unless + * tunneling really is required. * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder * exists. * @throws DecoderQueryException If there was an error querying the available decoders. */ - public static MediaCodecInfo getDecoderInfo(String mimeType, boolean secure) + public static MediaCodecInfo getDecoderInfo(String mimeType, boolean secure, boolean tunneling) throws DecoderQueryException { List decoderInfos = getDecoderInfos(mimeType, secure); - return decoderInfos.isEmpty() ? null : decoderInfos.get(0); + if (tunneling) { + for (int i = 0; i < decoderInfos.size(); i++) { + MediaCodecInfo decoderInfo = decoderInfos.get(i); + if (decoderInfo.tunneling) { + return decoderInfo; + } + } + return null; + } else { + return decoderInfos.isEmpty() ? null : decoderInfos.get(0); + } } /** @@ -177,11 +192,10 @@ public final class MediaCodecUtil { boolean secure = mediaCodecList.isSecurePlaybackSupported(mimeType, capabilities); if ((secureDecodersExplicit && key.secure == secure) || (!secureDecodersExplicit && !key.secure)) { - decoderInfos.add( - MediaCodecInfo.newInstance(codecName, mimeType, capabilities)); + decoderInfos.add(MediaCodecInfo.newInstance(codecName, mimeType, capabilities)); } else if (!secureDecodersExplicit && secure) { - decoderInfos.add(MediaCodecInfo.newInstance(codecName + ".secure", - mimeType, capabilities)); + decoderInfos.add(MediaCodecInfo.newInstance(codecName + ".secure", mimeType, + capabilities)); // It only makes sense to have one synthesized secure decoder, return immediately. return decoderInfos; } @@ -222,6 +236,7 @@ public final class MediaCodecUtil { && ("CIPAACDecoder".equals(name) || "CIPMP3Decoder".equals(name) || "CIPVorbisDecoder".equals(name) + || "CIPAMRNBDecoder".equals(name) || "AACDecoder".equals(name) || "MP3Decoder".equals(name))) { return false; @@ -290,13 +305,14 @@ public final class MediaCodecUtil { public static int maxH264DecodableFrameSize() throws DecoderQueryException { if (maxH264DecodableFrameSize == -1) { int result = 0; - MediaCodecInfo decoderInfo = getDecoderInfo(MimeTypes.VIDEO_H264, false); + MediaCodecInfo decoderInfo = getDecoderInfo(MimeTypes.VIDEO_H264, false, false); if (decoderInfo != null) { for (CodecProfileLevel profileLevel : decoderInfo.getProfileLevels()) { result = Math.max(avcLevelToMaxFrameSize(profileLevel.level), result); } - // We assume support for at least 360p. - result = Math.max(result, 480 * 360); + // We assume support for at least 480p (SDK_INT >= 21) or 360p (SDK_INT < 21), which are + // the levels mandated by the Android CDD. + result = Math.max(result, Util.SDK_INT >= 21 ? (720 * 480) : (480 * 360)); } maxH264DecodableFrameSize = result; } @@ -364,8 +380,8 @@ public final class MediaCodecUtil { Log.w(TAG, "Ignoring malformed AVC codec string: " + codec); return null; } - Integer profileInteger = null; - Integer levelInteger = null; + Integer profileInteger; + Integer levelInteger; try { if (codecsParts[1].length() == 6) { // Format: avc1.xxccyy, where xx is profile and yy level, both hexadecimal. @@ -555,13 +571,13 @@ public final class MediaCodecUtil { } static { - AVC_PROFILE_NUMBER_TO_CONST = new HashMap<>(); + AVC_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); AVC_PROFILE_NUMBER_TO_CONST.put(66, CodecProfileLevel.AVCProfileBaseline); AVC_PROFILE_NUMBER_TO_CONST.put(77, CodecProfileLevel.AVCProfileMain); AVC_PROFILE_NUMBER_TO_CONST.put(88, CodecProfileLevel.AVCProfileExtended); AVC_PROFILE_NUMBER_TO_CONST.put(100, CodecProfileLevel.AVCProfileHigh); - AVC_LEVEL_NUMBER_TO_CONST = new HashMap<>(); + AVC_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); AVC_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.AVCLevel1); // TODO: Find int for CodecProfileLevel.AVCLevel1b. AVC_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.AVCLevel11); diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java b/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java new file mode 100644 index 0000000000..40c05a5602 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata; + +import android.os.Parcel; +import android.os.Parcelable; +import java.util.Arrays; +import java.util.List; + +/** + * A collection of metadata entries. + */ +public final class Metadata implements Parcelable { + + /** + * A metadata entry. + */ + public interface Entry extends Parcelable {} + + private final Entry[] entries; + + /** + * @param entries The metadata entries. + */ + public Metadata(Entry... entries) { + this.entries = entries == null ? new Entry[0] : entries; + } + + /** + * @param entries The metadata entries. + */ + public Metadata(List entries) { + if (entries != null) { + this.entries = new Entry[entries.size()]; + entries.toArray(this.entries); + } else { + this.entries = new Entry[0]; + } + } + + /* package */ Metadata(Parcel in) { + entries = new Metadata.Entry[in.readInt()]; + for (int i = 0; i < entries.length; i++) { + entries[i] = in.readParcelable(Entry.class.getClassLoader()); + } + } + + /** + * Returns the number of metadata entries. + */ + public int length() { + return entries.length; + } + + /** + * Returns the entry at the specified index. + * + * @param index The index of the entry. + * @return The entry at the specified index. + */ + public Metadata.Entry get(int index) { + return entries[index]; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Metadata other = (Metadata) obj; + return Arrays.equals(entries, other.entries); + } + + @Override + public int hashCode() { + return Arrays.hashCode(entries); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(entries.length); + for (Entry entry : entries) { + dest.writeParcelable(entry, 0); + } + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Metadata createFromParcel(Parcel in) { + return new Metadata(in); + } + + @Override + public Metadata[] newArray(int size) { + return new Metadata[0]; + } + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java index 7cde1f243d..a73311f16b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java @@ -17,10 +17,8 @@ package com.google.android.exoplayer2.metadata; /** * Decodes metadata from binary data. - * - * @param The type of the metadata. */ -public interface MetadataDecoder { +public interface MetadataDecoder { /** * Checks whether the decoder supports a given mime type. @@ -38,6 +36,6 @@ public interface MetadataDecoder { * @return The decoded metadata object. * @throws MetadataDecoderException If a problem occurred decoding the data. */ - T decode(byte[] data, int size) throws MetadataDecoderException; + Metadata decode(byte[] data, int size) throws MetadataDecoderException; } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index aca38a1258..ff1364610b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -30,38 +30,34 @@ import java.nio.ByteBuffer; /** * A renderer for metadata. - * - * @param The type of the metadata. */ -public final class MetadataRenderer extends BaseRenderer implements Callback { +public final class MetadataRenderer extends BaseRenderer implements Callback { /** * Receives output from a {@link MetadataRenderer}. - * - * @param The type of the metadata. */ - public interface Output { + public interface Output { /** * Called each time there is a metadata associated with current playback time. * * @param metadata The metadata. */ - void onMetadata(T metadata); + void onMetadata(Metadata metadata); } private static final int MSG_INVOKE_RENDERER = 0; - private final MetadataDecoder metadataDecoder; - private final Output output; + private final MetadataDecoder metadataDecoder; + private final Output output; private final Handler outputHandler; private final FormatHolder formatHolder; private final DecoderInputBuffer buffer; private boolean inputStreamEnded; private long pendingMetadataTimestamp; - private T pendingMetadata; + private Metadata pendingMetadata; /** * @param output The output. @@ -72,8 +68,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback * called directly on the player's internal rendering thread. * @param metadataDecoder A decoder for the metadata. */ - public MetadataRenderer(Output output, Looper outputLooper, - MetadataDecoder metadataDecoder) { + public MetadataRenderer(Output output, Looper outputLooper, MetadataDecoder metadataDecoder) { super(C.TRACK_TYPE_METADATA); this.output = Assertions.checkNotNull(output); this.outputHandler = outputLooper == null ? null : new Handler(outputLooper, this); @@ -137,7 +132,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback return true; } - private void invokeRenderer(T metadata) { + private void invokeRenderer(Metadata metadata) { if (outputHandler != null) { outputHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); } else { @@ -150,13 +145,13 @@ public final class MetadataRenderer extends BaseRenderer implements Callback public boolean handleMessage(Message msg) { switch (msg.what) { case MSG_INVOKE_RENDERER: - invokeRendererInternal((T) msg.obj); + invokeRendererInternal((Metadata) msg.obj); return true; } return false; } - private void invokeRendererInternal(T metadata) { + private void invokeRendererInternal(Metadata metadata) { output.onMetadata(metadata); } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java index d2a04bdb94..c64be24a31 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ApicFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * APIC (Attached Picture) ID3 frame. */ @@ -35,4 +40,58 @@ public final class ApicFrame extends Id3Frame { this.pictureData = pictureData; } + /* package */ ApicFrame(Parcel in) { + super(ID); + mimeType = in.readString(); + description = in.readString(); + pictureType = in.readInt(); + pictureData = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ApicFrame other = (ApicFrame) obj; + return pictureType == other.pictureType && Util.areEqual(mimeType, other.mimeType) + && Util.areEqual(description, other.description) + && Arrays.equals(pictureData, other.pictureData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + pictureType; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(pictureData); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(description); + dest.writeInt(pictureType); + dest.writeByteArray(pictureData); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public ApicFrame createFromParcel(Parcel in) { + return new ApicFrame(in); + } + + @Override + public ApicFrame[] newArray(int size) { + return new ApicFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java index 5bc4ce3829..f662c1d06f 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import java.util.Arrays; + /** * Binary ID3 frame. */ @@ -22,9 +26,55 @@ public final class BinaryFrame extends Id3Frame { public final byte[] data; - public BinaryFrame(String type, byte[] data) { - super(type); + public BinaryFrame(String id, byte[] data) { + super(id); this.data = data; } + /* package */ BinaryFrame(Parcel in) { + super(in.readString()); + data = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BinaryFrame other = (BinaryFrame) obj; + return id.equals(other.id) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public BinaryFrame createFromParcel(Parcel in) { + return new BinaryFrame(in); + } + + @Override + public BinaryFrame[] newArray(int size) { + return new BinaryFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java new file mode 100644 index 0000000000..b7cc937ac4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/CommentFrame.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.id3; + +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + +/** + * Comment ID3 frame. + */ +public final class CommentFrame extends Id3Frame { + + public static final String ID = "COMM"; + + public final String language; + public final String description; + public final String text; + + public CommentFrame(String language, String description, String text) { + super(ID); + this.language = language; + this.description = description; + this.text = text; + } + + /* package */ CommentFrame(Parcel in) { + super(ID); + language = in.readString(); + description = in.readString(); + text = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + CommentFrame other = (CommentFrame) obj; + return Util.areEqual(description, other.description) && Util.areEqual(language, other.language) + && Util.areEqual(text, other.text); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (language != null ? language.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (text != null ? text.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(language); + dest.writeString(text); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public CommentFrame createFromParcel(Parcel in) { + return new CommentFrame(in); + } + + @Override + public CommentFrame[] newArray(int size) { + return new CommentFrame[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java index 4b77a69b27..79e145fc7c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/GeobFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * GEOB (General Encapsulated Object) ID3 frame. */ @@ -35,4 +40,57 @@ public final class GeobFrame extends Id3Frame { this.data = data; } + /* package */ GeobFrame(Parcel in) { + super(ID); + mimeType = in.readString(); + filename = in.readString(); + description = in.readString(); + data = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GeobFrame other = (GeobFrame) obj; + return Util.areEqual(mimeType, other.mimeType) && Util.areEqual(filename, other.filename) + && Util.areEqual(description, other.description) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (filename != null ? filename.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(filename); + dest.writeString(description); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public GeobFrame createFromParcel(Parcel in) { + return new GeobFrame(in); + } + + @Override + public GeobFrame[] newArray(int size) { + return new GeobFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 92c6efb530..d27c4f06e9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -15,22 +15,33 @@ */ package com.google.android.exoplayer2.metadata.id3; -import com.google.android.exoplayer2.ParserException; +import android.util.Log; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; -import com.google.android.exoplayer2.metadata.MetadataDecoderException; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Locale; /** - * Decodes individual TXXX text frames from raw ID3 data. + * Decodes ID3 tags. */ -public final class Id3Decoder implements MetadataDecoder> { +public final class Id3Decoder implements MetadataDecoder { + + private static final String TAG = "Id3Decoder"; + + /** + * The first three bytes of a well formed ID3 tag header. + */ + public static final int ID3_TAG = Util.getIntegerCodeForString("ID3"); + /** + * Length of an ID3 tag header. + */ + public static final int ID3_HEADER_LENGTH = 10; private static final int ID3_TEXT_ENCODING_ISO_8859_1 = 0; private static final int ID3_TEXT_ENCODING_UTF_16 = 1; @@ -43,117 +54,247 @@ public final class Id3Decoder implements MetadataDecoder> { } @Override - public List decode(byte[] data, int size) throws MetadataDecoderException { + public Metadata decode(byte[] data, int size) { List id3Frames = new ArrayList<>(); ParsableByteArray id3Data = new ParsableByteArray(data, size); - int id3Size = decodeId3Header(id3Data); - while (id3Size > 0) { - int frameId0 = id3Data.readUnsignedByte(); - int frameId1 = id3Data.readUnsignedByte(); - int frameId2 = id3Data.readUnsignedByte(); - int frameId3 = id3Data.readUnsignedByte(); - int frameSize = id3Data.readSynchSafeInt(); - if (frameSize <= 1) { - break; - } + Id3Header id3Header = decodeHeader(id3Data); + if (id3Header == null) { + return null; + } - // Skip frame flags. - id3Data.skipBytes(2); + int startPosition = id3Data.getPosition(); + int framesSize = id3Header.framesSize; + if (id3Header.isUnsynchronized) { + framesSize = removeUnsynchronization(id3Data, id3Header.framesSize); + } + id3Data.setLimit(startPosition + framesSize); - try { - Id3Frame frame; - if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && frameId3 == 'X') { - frame = decodeTxxxFrame(id3Data, frameSize); - } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { - frame = decodePrivFrame(id3Data, frameSize); - } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' && frameId3 == 'B') { - frame = decodeGeobFrame(id3Data, frameSize); - } else if (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C') { - frame = decodeApicFrame(id3Data, frameSize); - } else if (frameId0 == 'T') { - String id = String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); - frame = decodeTextInformationFrame(id3Data, frameSize, id); + boolean unsignedIntFrameSizeHack = false; + if (id3Header.majorVersion == 4) { + if (!validateV4Frames(id3Data, false)) { + if (validateV4Frames(id3Data, true)) { + unsignedIntFrameSizeHack = true; } else { - String id = String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); - frame = decodeBinaryFrame(id3Data, frameSize, id); + Log.w(TAG, "Failed to validate V4 ID3 tag"); + return null; } + } + } + + int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10; + while (id3Data.bytesLeft() >= frameHeaderSize) { + Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack); + if (frame != null) { id3Frames.add(frame); - id3Size -= frameSize + 10 /* header size */; - } catch (UnsupportedEncodingException e) { - throw new MetadataDecoderException("Unsupported encoding", e); } } - return Collections.unmodifiableList(id3Frames); - } - - private static int indexOfEos(byte[] data, int fromIndex, int encoding) { - int terminationPos = indexOfZeroByte(data, fromIndex); - - // For single byte encoding charsets, we're done. - if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) { - return terminationPos; - } - - // Otherwise look for a second zero byte. - while (terminationPos < data.length - 1) { - if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) { - return terminationPos; - } - terminationPos = indexOfZeroByte(data, terminationPos + 1); - } - - return data.length; - } - - private static int indexOfZeroByte(byte[] data, int fromIndex) { - for (int i = fromIndex; i < data.length; i++) { - if (data[i] == (byte) 0) { - return i; - } - } - return data.length; - } - - private static int delimiterLength(int encodingByte) { - return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8) - ? 1 : 2; + return new Metadata(id3Frames); } /** - * @param id3Buffer A {@link ParsableByteArray} from which data should be read. - * @return The size of ID3 frames in bytes, excluding the header and footer. - * @throws ParserException If ID3 file identifier != "ID3". + * @param data A {@link ParsableByteArray} from which the header should be read. + * @return The parsed header, or null if the ID3 tag is unsupported. */ - private static int decodeId3Header(ParsableByteArray id3Buffer) throws MetadataDecoderException { - int id1 = id3Buffer.readUnsignedByte(); - int id2 = id3Buffer.readUnsignedByte(); - int id3 = id3Buffer.readUnsignedByte(); - if (id1 != 'I' || id2 != 'D' || id3 != '3') { - throw new MetadataDecoderException(String.format(Locale.US, - "Unexpected ID3 file identifier, expected \"ID3\", actual \"%c%c%c\".", id1, id2, id3)); + private static Id3Header decodeHeader(ParsableByteArray data) { + if (data.bytesLeft() < ID3_HEADER_LENGTH) { + Log.w(TAG, "Data too short to be an ID3 tag"); + return null; } - id3Buffer.skipBytes(2); // Skip version. - int flags = id3Buffer.readUnsignedByte(); - int id3Size = id3Buffer.readSynchSafeInt(); + int id = data.readUnsignedInt24(); + if (id != ID3_TAG) { + Log.w(TAG, "Unexpected first three bytes of ID3 tag header: " + id); + return null; + } - // Check if extended header presents. - if ((flags & 0x2) != 0) { - int extendedHeaderSize = id3Buffer.readSynchSafeInt(); - if (extendedHeaderSize > 4) { - id3Buffer.skipBytes(extendedHeaderSize - 4); + int majorVersion = data.readUnsignedByte(); + data.skipBytes(1); // Skip minor version. + int flags = data.readUnsignedByte(); + int framesSize = data.readSynchSafeInt(); + + if (majorVersion == 2) { + boolean isCompressed = (flags & 0x40) != 0; + if (isCompressed) { + Log.w(TAG, "Skipped ID3 tag with majorVersion=2 and undefined compression scheme"); + return null; } - id3Size -= extendedHeaderSize; + } else if (majorVersion == 3) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readInt(); // Size excluding size field. + data.skipBytes(extendedHeaderSize); + framesSize -= (extendedHeaderSize + 4); + } + } else if (majorVersion == 4) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readSynchSafeInt(); // Size including size field. + data.skipBytes(extendedHeaderSize - 4); + framesSize -= extendedHeaderSize; + } + boolean hasFooter = (flags & 0x10) != 0; + if (hasFooter) { + framesSize -= 10; + } + } else { + Log.w(TAG, "Skipped ID3 tag with unsupported majorVersion=" + majorVersion); + return null; } - // Check if footer presents. - if ((flags & 0x8) != 0) { - id3Size -= 10; + // isUnsynchronized is advisory only in version 4. Frame level flags are used instead. + boolean isUnsynchronized = majorVersion < 4 && (flags & 0x80) != 0; + return new Id3Header(majorVersion, isUnsynchronized, framesSize); + } + + private static boolean validateV4Frames(ParsableByteArray id3Data, + boolean unsignedIntFrameSizeHack) { + int startPosition = id3Data.getPosition(); + try { + while (id3Data.bytesLeft() >= 10) { + int id = id3Data.readInt(); + int frameSize = id3Data.readUnsignedIntToInt(); + int flags = id3Data.readUnsignedShort(); + if (id == 0 && frameSize == 0 && flags == 0) { + return true; + } else { + if (!unsignedIntFrameSizeHack) { + // Parse the data size as a synchsafe integer, as per the spec. + if ((frameSize & 0x808080L) != 0) { + return false; + } + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + int minimumFrameSize = 0; + if ((flags & 0x0040) != 0 /* hasGroupIdentifier */) { + minimumFrameSize++; + } + if ((flags & 0x0001) != 0 /* hasDataLength */) { + minimumFrameSize += 4; + } + if (frameSize < minimumFrameSize) { + return false; + } + if (id3Data.bytesLeft() < frameSize) { + return false; + } + id3Data.skipBytes(frameSize); // flags + } + } + return true; + } finally { + id3Data.setPosition(startPosition); + } + } + + private static Id3Frame decodeFrame(int majorVersion, ParsableByteArray id3Data, + boolean unsignedIntFrameSizeHack) { + int frameId0 = id3Data.readUnsignedByte(); + int frameId1 = id3Data.readUnsignedByte(); + int frameId2 = id3Data.readUnsignedByte(); + int frameId3 = majorVersion >= 3 ? id3Data.readUnsignedByte() : 0; + + int frameSize; + if (majorVersion == 4) { + frameSize = id3Data.readUnsignedIntToInt(); + if (!unsignedIntFrameSizeHack) { + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + } else if (majorVersion == 3) { + frameSize = id3Data.readUnsignedIntToInt(); + } else /* id3Header.majorVersion == 2 */ { + frameSize = id3Data.readUnsignedInt24(); } - return id3Size; + int flags = majorVersion >= 3 ? id3Data.readUnsignedShort() : 0; + if (frameId0 == 0 && frameId1 == 0 && frameId2 == 0 && frameId3 == 0 && frameSize == 0 + && flags == 0) { + // We must be reading zero padding at the end of the tag. + id3Data.setPosition(id3Data.limit()); + return null; + } + + int nextFramePosition = id3Data.getPosition() + frameSize; + if (nextFramePosition > id3Data.limit()) { + Log.w(TAG, "Frame size exceeds remaining tag data"); + id3Data.setPosition(id3Data.limit()); + return null; + } + + // Frame flags. + boolean isCompressed = false; + boolean isEncrypted = false; + boolean isUnsynchronized = false; + boolean hasDataLength = false; + boolean hasGroupIdentifier = false; + if (majorVersion == 3) { + isCompressed = (flags & 0x0080) != 0; + isEncrypted = (flags & 0x0040) != 0; + hasGroupIdentifier = (flags & 0x0020) != 0; + hasDataLength = isCompressed; + } else if (majorVersion == 4) { + hasGroupIdentifier = (flags & 0x0040) != 0; + isCompressed = (flags & 0x0008) != 0; + isEncrypted = (flags & 0x0004) != 0; + isUnsynchronized = (flags & 0x0002) != 0; + hasDataLength = (flags & 0x0001) != 0; + } + + if (isCompressed || isEncrypted) { + Log.w(TAG, "Skipping unsupported compressed or encrypted frame"); + id3Data.setPosition(nextFramePosition); + return null; + } + + if (hasGroupIdentifier) { + frameSize--; + id3Data.skipBytes(1); + } + if (hasDataLength) { + frameSize -= 4; + id3Data.skipBytes(4); + } + if (isUnsynchronized) { + frameSize = removeUnsynchronization(id3Data, frameSize); + } + + try { + Id3Frame frame; + if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' + && (majorVersion == 2 || frameId3 == 'X')) { + frame = decodeTxxxFrame(id3Data, frameSize); + } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { + frame = decodePrivFrame(id3Data, frameSize); + } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' + && (frameId3 == 'B' || majorVersion == 2)) { + frame = decodeGeobFrame(id3Data, frameSize); + } else if (majorVersion == 2 ? (frameId0 == 'P' && frameId1 == 'I' && frameId2 == 'C') + : (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C')) { + frame = decodeApicFrame(id3Data, frameSize, majorVersion); + } else if (frameId0 == 'T') { + String id = majorVersion == 2 + ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) + : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + frame = decodeTextInformationFrame(id3Data, frameSize, id); + } else if (frameId0 == 'C' && frameId1 == 'O' && frameId2 == 'M' + && (frameId3 == 'M' || majorVersion == 2)) { + frame = decodeCommentFrame(id3Data, frameSize); + } else { + String id = majorVersion == 2 + ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) + : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + frame = decodeBinaryFrame(id3Data, frameSize, id); + } + return frame; + } catch (UnsupportedEncodingException e) { + Log.w(TAG, "Unsupported character encoding"); + return null; + } finally { + id3Data.setPosition(nextFramePosition); + } } private static TxxxFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) @@ -215,16 +356,29 @@ public final class Id3Decoder implements MetadataDecoder> { return new GeobFrame(mimeType, filename, description, objectData); } - private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize) - throws UnsupportedEncodingException { + private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize, + int majorVersion) throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); String charset = getCharsetName(encoding); byte[] data = new byte[frameSize - 1]; id3Data.readBytes(data, 0, frameSize - 1); - int mimeTypeEndIndex = indexOfZeroByte(data, 0); - String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1"); + String mimeType; + int mimeTypeEndIndex; + if (majorVersion == 2) { + mimeTypeEndIndex = 2; + mimeType = "image/" + Util.toLowerInvariant(new String(data, 0, 3, "ISO-8859-1")); + if (mimeType.equals("image/jpg")) { + mimeType = "image/jpeg"; + } + } else { + mimeTypeEndIndex = indexOfZeroByte(data, 0); + mimeType = Util.toLowerInvariant(new String(data, 0, mimeTypeEndIndex, "ISO-8859-1")); + if (mimeType.indexOf('/') == -1) { + mimeType = "image/" + mimeType; + } + } int pictureType = data[mimeTypeEndIndex + 1] & 0xFF; @@ -239,6 +393,28 @@ public final class Id3Decoder implements MetadataDecoder> { return new ApicFrame(mimeType, description, pictureType, pictureData); } + private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[3]; + id3Data.readBytes(data, 0, 3); + String language = new String(data, 0, 3); + + data = new byte[frameSize - 4]; + id3Data.readBytes(data, 0, frameSize - 4); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + int textStartIndex = descriptionEndIndex + delimiterLength(encoding); + int textEndIndex = indexOfEos(data, textStartIndex, encoding); + String text = new String(data, textStartIndex, textEndIndex - textStartIndex, charset); + + return new CommentFrame(language, description, text); + } + private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); @@ -261,6 +437,25 @@ public final class Id3Decoder implements MetadataDecoder> { return new BinaryFrame(id, frame); } + /** + * Performs in-place removal of unsynchronization for {@code length} bytes starting from + * {@link ParsableByteArray#getPosition()} + * + * @param data Contains the data to be processed. + * @param length The length of the data to be processed. + * @return The length of the data after processing. + */ + private static int removeUnsynchronization(ParsableByteArray data, int length) { + byte[] bytes = data.data; + for (int i = data.getPosition(); i + 1 < length; i++) { + if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { + System.arraycopy(bytes, i + 2, bytes, i + 1, length - i - 2); + length--; + } + } + return length; + } + /** * Maps encoding byte from ID3v2 frame to a Charset. * @param encodingByte The value of encoding byte from ID3v2 frame. @@ -281,4 +476,51 @@ public final class Id3Decoder implements MetadataDecoder> { } } + private static int indexOfEos(byte[] data, int fromIndex, int encoding) { + int terminationPos = indexOfZeroByte(data, fromIndex); + + // For single byte encoding charsets, we're done. + if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) { + return terminationPos; + } + + // Otherwise ensure an even index and look for a second zero byte. + while (terminationPos < data.length - 1) { + if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) { + return terminationPos; + } + terminationPos = indexOfZeroByte(data, terminationPos + 1); + } + + return data.length; + } + + private static int indexOfZeroByte(byte[] data, int fromIndex) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] == (byte) 0) { + return i; + } + } + return data.length; + } + + private static int delimiterLength(int encodingByte) { + return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8) + ? 1 : 2; + } + + private static final class Id3Header { + + private final int majorVersion; + private final boolean isUnsynchronized; + private final int framesSize; + + public Id3Header(int majorVersion, boolean isUnsynchronized, int framesSize) { + this.majorVersion = majorVersion; + this.isUnsynchronized = isUnsynchronized; + this.framesSize = framesSize; + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java index 903b32da4f..9948f730eb 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Frame.java @@ -15,10 +15,13 @@ */ package com.google.android.exoplayer2.metadata.id3; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Assertions; + /** * Base class for ID3 frames. */ -public abstract class Id3Frame { +public abstract class Id3Frame implements Metadata.Entry { /** * The frame ID. @@ -26,7 +29,12 @@ public abstract class Id3Frame { public final String id; public Id3Frame(String id) { - this.id = id; + this.id = Assertions.checkNotNull(id); + } + + @Override + public int describeContents() { + return 0; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java index bbfbd96b84..fe55f5ddc0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/PrivFrame.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + /** * PRIV (Private) ID3 frame. */ @@ -31,4 +36,50 @@ public final class PrivFrame extends Id3Frame { this.privateData = privateData; } + /* package */ PrivFrame(Parcel in) { + super(ID); + owner = in.readString(); + privateData = in.createByteArray(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PrivFrame other = (PrivFrame) obj; + return Util.areEqual(owner, other.owner) && Arrays.equals(privateData, other.privateData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (owner != null ? owner.hashCode() : 0); + result = 31 * result + Arrays.hashCode(privateData); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(owner); + dest.writeByteArray(privateData); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public PrivFrame createFromParcel(Parcel in) { + return new PrivFrame(in); + } + + @Override + public PrivFrame[] newArray(int size) { + return new PrivFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java index ec05a8ff4b..b8c061fd0a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + /** * Text information ("T000" - "TZZZ", excluding "TXXX") ID3 frame. */ @@ -27,4 +31,50 @@ public final class TextInformationFrame extends Id3Frame { this.description = description; } + /* package */ TextInformationFrame(Parcel in) { + super(in.readString()); + description = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TextInformationFrame other = (TextInformationFrame) obj; + return id.equals(other.id) && Util.areEqual(description, other.description); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(description); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public TextInformationFrame createFromParcel(Parcel in) { + return new TextInformationFrame(in); + } + + @Override + public TextInformationFrame[] newArray(int size) { + return new TextInformationFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java index 6593c2f120..5c24e70ef4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.metadata.id3; +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.Util; + /** * TXXX (User defined text information) ID3 frame. */ @@ -31,4 +35,50 @@ public final class TxxxFrame extends Id3Frame { this.value = value; } + /* package */ TxxxFrame(Parcel in) { + super(ID); + description = in.readString(); + value = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TxxxFrame other = (TxxxFrame) obj; + return Util.areEqual(description, other.description) && Util.areEqual(value, other.value); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(description); + dest.writeString(value); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + + @Override + public TxxxFrame createFromParcel(Parcel in) { + return new TxxxFrame(in); + } + + @Override + public TxxxFrame[] newArray(int size) { + return new TxxxFrame[size]; + } + + }; + } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java new file mode 100644 index 0000000000..f75a1b46a4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Represents a private command as defined in SCTE35, Section 9.3.6. + */ +public final class PrivateCommand extends SpliceCommand { + + public final long ptsAdjustment; + public final long identifier; + + public final byte[] commandBytes; + + private PrivateCommand(long identifier, byte[] commandBytes, long ptsAdjustment) { + this.ptsAdjustment = ptsAdjustment; + this.identifier = identifier; + this.commandBytes = commandBytes; + } + + private PrivateCommand(Parcel in) { + ptsAdjustment = in.readLong(); + identifier = in.readLong(); + commandBytes = new byte[in.readInt()]; + in.readByteArray(commandBytes); + } + + /* package */ static PrivateCommand parseFromSection(ParsableByteArray sectionData, + int commandLength, long ptsAdjustment) { + long identifier = sectionData.readUnsignedInt(); + byte[] privateBytes = new byte[commandLength - 4 /* identifier size */]; + sectionData.readBytes(privateBytes, 0, privateBytes.length); + return new PrivateCommand(identifier, privateBytes, ptsAdjustment); + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(ptsAdjustment); + dest.writeLong(identifier); + dest.writeInt(commandBytes.length); + dest.writeByteArray(commandBytes); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public PrivateCommand createFromParcel(Parcel in) { + return new PrivateCommand(in); + } + + @Override + public PrivateCommand[] newArray(int size) { + return new PrivateCommand[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java new file mode 100644 index 0000000000..8dfa3b8942 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import com.google.android.exoplayer2.metadata.Metadata; + +/** + * Superclass for SCTE35 splice commands. + */ +public abstract class SpliceCommand implements Metadata.Entry { + + @Override + public int describeContents() { + return 0; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java new file mode 100644 index 0000000000..5af0f25481 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.text.TextUtils; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.MetadataDecoder; +import com.google.android.exoplayer2.metadata.MetadataDecoderException; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Decodes splice info sections and produces splice commands. + */ +public final class SpliceInfoDecoder implements MetadataDecoder { + + private static final int TYPE_SPLICE_NULL = 0x00; + private static final int TYPE_SPLICE_SCHEDULE = 0x04; + private static final int TYPE_SPLICE_INSERT = 0x05; + private static final int TYPE_TIME_SIGNAL = 0x06; + private static final int TYPE_PRIVATE_COMMAND = 0xFF; + + private final ParsableByteArray sectionData; + private final ParsableBitArray sectionHeader; + + public SpliceInfoDecoder() { + sectionData = new ParsableByteArray(); + sectionHeader = new ParsableBitArray(); + } + + @Override + public boolean canDecode(String mimeType) { + return TextUtils.equals(mimeType, MimeTypes.APPLICATION_SCTE35); + } + + @Override + public Metadata decode(byte[] data, int size) throws MetadataDecoderException { + sectionData.reset(data, size); + sectionHeader.reset(data, size); + // table_id(8), section_syntax_indicator(1), private_indicator(1), reserved(2), + // section_length(12), protocol_version(8), encrypted_packet(1), encryption_algorithm(6). + sectionHeader.skipBits(39); + long ptsAdjustment = sectionHeader.readBits(1); + ptsAdjustment = (ptsAdjustment << 32) | sectionHeader.readBits(32); + // cw_index(8), tier(12). + sectionHeader.skipBits(20); + int spliceCommandLength = sectionHeader.readBits(12); + int spliceCommandType = sectionHeader.readBits(8); + SpliceCommand command = null; + // Go to the start of the command by skipping all fields up to command_type. + sectionData.skipBytes(14); + switch (spliceCommandType) { + case TYPE_SPLICE_NULL: + command = new SpliceNullCommand(); + break; + case TYPE_SPLICE_SCHEDULE: + command = SpliceScheduleCommand.parseFromSection(sectionData); + break; + case TYPE_SPLICE_INSERT: + command = SpliceInsertCommand.parseFromSection(sectionData, ptsAdjustment); + break; + case TYPE_TIME_SIGNAL: + command = TimeSignalCommand.parseFromSection(sectionData, ptsAdjustment); + break; + case TYPE_PRIVATE_COMMAND: + command = PrivateCommand.parseFromSection(sectionData, spliceCommandLength, ptsAdjustment); + break; + } + return command == null ? new Metadata() : new Metadata(command); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java new file mode 100644 index 0000000000..1e025aeb35 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a splice insert command defined in SCTE35, Section 9.3.3. + */ +public final class SpliceInsertCommand extends SpliceCommand { + + public final long spliceEventId; + public final boolean spliceEventCancelIndicator; + public final boolean outOfNetworkIndicator; + public final boolean programSpliceFlag; + public final boolean spliceImmediateFlag; + public final long programSplicePts; + public final List componentSpliceList; + public final boolean autoReturn; + public final long breakDuration; + public final int uniqueProgramId; + public final int availNum; + public final int availsExpected; + + private SpliceInsertCommand(long spliceEventId, boolean spliceEventCancelIndicator, + boolean outOfNetworkIndicator, boolean programSpliceFlag, boolean spliceImmediateFlag, + long programSplicePts, List componentSpliceList, boolean autoReturn, + long breakDuration, int uniqueProgramId, int availNum, int availsExpected) { + this.spliceEventId = spliceEventId; + this.spliceEventCancelIndicator = spliceEventCancelIndicator; + this.outOfNetworkIndicator = outOfNetworkIndicator; + this.programSpliceFlag = programSpliceFlag; + this.spliceImmediateFlag = spliceImmediateFlag; + this.programSplicePts = programSplicePts; + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.autoReturn = autoReturn; + this.breakDuration = breakDuration; + this.uniqueProgramId = uniqueProgramId; + this.availNum = availNum; + this.availsExpected = availsExpected; + } + + private SpliceInsertCommand(Parcel in) { + spliceEventId = in.readLong(); + spliceEventCancelIndicator = in.readByte() == 1; + outOfNetworkIndicator = in.readByte() == 1; + programSpliceFlag = in.readByte() == 1; + spliceImmediateFlag = in.readByte() == 1; + programSplicePts = in.readLong(); + int componentSpliceListSize = in.readInt(); + List componentSpliceList = new ArrayList<>(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.add(ComponentSplice.createFromParcel(in)); + } + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + autoReturn = in.readByte() == 1; + breakDuration = in.readLong(); + uniqueProgramId = in.readInt(); + availNum = in.readInt(); + availsExpected = in.readInt(); + } + + /* package */ static SpliceInsertCommand parseFromSection(ParsableByteArray sectionData, + long ptsAdjustment) { + long spliceEventId = sectionData.readUnsignedInt(); + // splice_event_cancel_indicator(1), reserved(7). + boolean spliceEventCancelIndicator = (sectionData.readUnsignedByte() & 0x80) != 0; + boolean outOfNetworkIndicator = false; + boolean programSpliceFlag = false; + boolean spliceImmediateFlag = false; + long programSplicePts = C.TIME_UNSET; + ArrayList componentSplices = new ArrayList<>(); + int uniqueProgramId = 0; + int availNum = 0; + int availsExpected = 0; + boolean autoReturn = false; + long duration = C.TIME_UNSET; + if (!spliceEventCancelIndicator) { + int headerByte = sectionData.readUnsignedByte(); + outOfNetworkIndicator = (headerByte & 0x80) != 0; + programSpliceFlag = (headerByte & 0x40) != 0; + boolean durationFlag = (headerByte & 0x20) != 0; + spliceImmediateFlag = (headerByte & 0x10) != 0; + if (programSpliceFlag && !spliceImmediateFlag) { + programSplicePts = TimeSignalCommand.parseSpliceTime(sectionData, ptsAdjustment); + } + if (!programSpliceFlag) { + int componentCount = sectionData.readUnsignedByte(); + componentSplices = new ArrayList<>(componentCount); + for (int i = 0; i < componentCount; i++) { + int componentTag = sectionData.readUnsignedByte(); + long componentSplicePts = C.TIME_UNSET; + if (!spliceImmediateFlag) { + componentSplicePts = TimeSignalCommand.parseSpliceTime(sectionData, ptsAdjustment); + } + componentSplices.add(new ComponentSplice(componentTag, componentSplicePts)); + } + } + if (durationFlag) { + long firstByte = sectionData.readUnsignedByte(); + autoReturn = (firstByte & 0x80) != 0; + duration = ((firstByte & 0x01) << 32) | sectionData.readUnsignedInt(); + } + uniqueProgramId = sectionData.readUnsignedShort(); + availNum = sectionData.readUnsignedByte(); + availsExpected = sectionData.readUnsignedByte(); + } + return new SpliceInsertCommand(spliceEventId, spliceEventCancelIndicator, outOfNetworkIndicator, + programSpliceFlag, spliceImmediateFlag, programSplicePts, componentSplices, autoReturn, + duration, uniqueProgramId, availNum, availsExpected); + } + + /** + * Holds splicing information for specific splice insert command components. + */ + public static final class ComponentSplice { + + public final int componentTag; + public final long componentSplicePts; + + private ComponentSplice(int componentTag, long componentSplicePts) { + this.componentTag = componentTag; + this.componentSplicePts = componentSplicePts; + } + + public void writeToParcel(Parcel dest) { + dest.writeInt(componentTag); + dest.writeLong(componentSplicePts); + } + + public static ComponentSplice createFromParcel(Parcel in) { + return new ComponentSplice(in.readInt(), in.readLong()); + } + + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(spliceEventId); + dest.writeByte((byte) (spliceEventCancelIndicator ? 1 : 0)); + dest.writeByte((byte) (outOfNetworkIndicator ? 1 : 0)); + dest.writeByte((byte) (programSpliceFlag ? 1 : 0)); + dest.writeByte((byte) (spliceImmediateFlag ? 1 : 0)); + dest.writeLong(programSplicePts); + int componentSpliceListSize = componentSpliceList.size(); + dest.writeInt(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.get(i).writeToParcel(dest); + } + dest.writeByte((byte) (autoReturn ? 1 : 0)); + dest.writeLong(breakDuration); + dest.writeInt(uniqueProgramId); + dest.writeInt(availNum); + dest.writeInt(availsExpected); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public SpliceInsertCommand createFromParcel(Parcel in) { + return new SpliceInsertCommand(in); + } + + @Override + public SpliceInsertCommand[] newArray(int size) { + return new SpliceInsertCommand[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java new file mode 100644 index 0000000000..461d49ebb4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; + +/** + * Represents a splice null command as defined in SCTE35, Section 9.3.1. + */ +public final class SpliceNullCommand extends SpliceCommand { + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + // Do nothing. + } + + public static final Creator CREATOR = + new Creator() { + + @Override + public SpliceNullCommand createFromParcel(Parcel in) { + return new SpliceNullCommand(); + } + + @Override + public SpliceNullCommand[] newArray(int size) { + return new SpliceNullCommand[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java new file mode 100644 index 0000000000..9b391cea6c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a splice schedule command as defined in SCTE35, Section 9.3.2. + */ +public final class SpliceScheduleCommand extends SpliceCommand { + + /** + * Represents a splice event as contained in a {@link SpliceScheduleCommand}. + */ + public static final class Event { + + public final long spliceEventId; + public final boolean spliceEventCancelIndicator; + public final boolean outOfNetworkIndicator; + public final boolean programSpliceFlag; + public final long utcSpliceTime; + public final List componentSpliceList; + public final boolean autoReturn; + public final long breakDuration; + public final int uniqueProgramId; + public final int availNum; + public final int availsExpected; + + private Event(long spliceEventId, boolean spliceEventCancelIndicator, + boolean outOfNetworkIndicator, boolean programSpliceFlag, + List componentSpliceList, long utcSpliceTime, boolean autoReturn, + long breakDuration, int uniqueProgramId, int availNum, int availsExpected) { + this.spliceEventId = spliceEventId; + this.spliceEventCancelIndicator = spliceEventCancelIndicator; + this.outOfNetworkIndicator = outOfNetworkIndicator; + this.programSpliceFlag = programSpliceFlag; + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.utcSpliceTime = utcSpliceTime; + this.autoReturn = autoReturn; + this.breakDuration = breakDuration; + this.uniqueProgramId = uniqueProgramId; + this.availNum = availNum; + this.availsExpected = availsExpected; + } + + private Event(Parcel in) { + this.spliceEventId = in.readLong(); + this.spliceEventCancelIndicator = in.readByte() == 1; + this.outOfNetworkIndicator = in.readByte() == 1; + this.programSpliceFlag = in.readByte() == 1; + int componentSpliceListLength = in.readInt(); + ArrayList componentSpliceList = new ArrayList<>(componentSpliceListLength); + for (int i = 0; i < componentSpliceListLength; i++) { + componentSpliceList.add(ComponentSplice.createFromParcel(in)); + } + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.utcSpliceTime = in.readLong(); + this.autoReturn = in.readByte() == 1; + this.breakDuration = in.readLong(); + this.uniqueProgramId = in.readInt(); + this.availNum = in.readInt(); + this.availsExpected = in.readInt(); + } + + private static Event parseFromSection(ParsableByteArray sectionData) { + long spliceEventId = sectionData.readUnsignedInt(); + // splice_event_cancel_indicator(1), reserved(7). + boolean spliceEventCancelIndicator = (sectionData.readUnsignedByte() & 0x80) != 0; + boolean outOfNetworkIndicator = false; + boolean programSpliceFlag = false; + long utcSpliceTime = C.TIME_UNSET; + ArrayList componentSplices = new ArrayList<>(); + int uniqueProgramId = 0; + int availNum = 0; + int availsExpected = 0; + boolean autoReturn = false; + long duration = C.TIME_UNSET; + if (!spliceEventCancelIndicator) { + int headerByte = sectionData.readUnsignedByte(); + outOfNetworkIndicator = (headerByte & 0x80) != 0; + programSpliceFlag = (headerByte & 0x40) != 0; + boolean durationFlag = (headerByte & 0x20) != 0; + if (programSpliceFlag) { + utcSpliceTime = sectionData.readUnsignedInt(); + } + if (!programSpliceFlag) { + int componentCount = sectionData.readUnsignedByte(); + componentSplices = new ArrayList<>(componentCount); + for (int i = 0; i < componentCount; i++) { + int componentTag = sectionData.readUnsignedByte(); + long componentUtcSpliceTime = sectionData.readUnsignedInt(); + componentSplices.add(new ComponentSplice(componentTag, componentUtcSpliceTime)); + } + } + if (durationFlag) { + long firstByte = sectionData.readUnsignedByte(); + autoReturn = (firstByte & 0x80) != 0; + duration = ((firstByte & 0x01) << 32) | sectionData.readUnsignedInt(); + } + uniqueProgramId = sectionData.readUnsignedShort(); + availNum = sectionData.readUnsignedByte(); + availsExpected = sectionData.readUnsignedByte(); + } + return new Event(spliceEventId, spliceEventCancelIndicator, outOfNetworkIndicator, + programSpliceFlag, componentSplices, utcSpliceTime, autoReturn, duration, uniqueProgramId, + availNum, availsExpected); + } + + private void writeToParcel(Parcel dest) { + dest.writeLong(spliceEventId); + dest.writeByte((byte) (spliceEventCancelIndicator ? 1 : 0)); + dest.writeByte((byte) (outOfNetworkIndicator ? 1 : 0)); + dest.writeByte((byte) (programSpliceFlag ? 1 : 0)); + int componentSpliceListSize = componentSpliceList.size(); + dest.writeInt(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.get(i).writeToParcel(dest); + } + dest.writeLong(utcSpliceTime); + dest.writeByte((byte) (autoReturn ? 1 : 0)); + dest.writeLong(breakDuration); + dest.writeInt(uniqueProgramId); + dest.writeInt(availNum); + dest.writeInt(availsExpected); + } + + private static Event createFromParcel(Parcel in) { + return new Event(in); + } + + } + + /** + * Holds splicing information for specific splice schedule command components. + */ + public static final class ComponentSplice { + + public final int componentTag; + public final long utcSpliceTime; + + private ComponentSplice(int componentTag, long utcSpliceTime) { + this.componentTag = componentTag; + this.utcSpliceTime = utcSpliceTime; + } + + private static ComponentSplice createFromParcel(Parcel in) { + return new ComponentSplice(in.readInt(), in.readLong()); + } + + private void writeToParcel(Parcel dest) { + dest.writeInt(componentTag); + dest.writeLong(utcSpliceTime); + } + + } + + public final List events; + + private SpliceScheduleCommand(List events) { + this.events = Collections.unmodifiableList(events); + } + + private SpliceScheduleCommand(Parcel in) { + int eventsSize = in.readInt(); + ArrayList events = new ArrayList<>(eventsSize); + for (int i = 0; i < eventsSize; i++) { + events.add(Event.createFromParcel(in)); + } + this.events = Collections.unmodifiableList(events); + } + + /* package */ static SpliceScheduleCommand parseFromSection(ParsableByteArray sectionData) { + int spliceCount = sectionData.readUnsignedByte(); + ArrayList events = new ArrayList<>(spliceCount); + for (int i = 0; i < spliceCount; i++) { + events.add(Event.parseFromSection(sectionData)); + } + return new SpliceScheduleCommand(events); + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + int eventsSize = events.size(); + dest.writeInt(eventsSize); + for (int i = 0; i < eventsSize; i++) { + events.get(i).writeToParcel(dest); + } + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public SpliceScheduleCommand createFromParcel(Parcel in) { + return new SpliceScheduleCommand(in); + } + + @Override + public SpliceScheduleCommand[] newArray(int size) { + return new SpliceScheduleCommand[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java new file mode 100644 index 0000000000..c31f4dedc8 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Represents a time signal command as defined in SCTE35, Section 9.3.4. + */ +public final class TimeSignalCommand extends SpliceCommand { + + public final long ptsTime; + + private TimeSignalCommand(long ptsTime) { + this.ptsTime = ptsTime; + } + + /* package */ static TimeSignalCommand parseFromSection(ParsableByteArray sectionData, + long ptsAdjustment) { + return new TimeSignalCommand(parseSpliceTime(sectionData, ptsAdjustment)); + } + + /** + * Parses pts_time from splice_time(), defined in Section 9.4.1. Returns {@link C#TIME_UNSET}, if + * time_specified_flag is false. + * + * @param sectionData The section data from which the pts_time is parsed. + * @param ptsAdjustment The pts adjustment provided by the splice info section header. + * @return The pts_time defined by splice_time(), or {@link C#TIME_UNSET}, if time_specified_flag + * is false. + */ + /* package */ static long parseSpliceTime(ParsableByteArray sectionData, long ptsAdjustment) { + long firstByte = sectionData.readUnsignedByte(); + long ptsTime = C.TIME_UNSET; + if ((firstByte & 0x80) != 0 /* time_specified_flag */) { + // See SCTE35 9.2.1 for more information about pts adjustment. + ptsTime = (firstByte & 0x01) << 32 | sectionData.readUnsignedInt(); + ptsTime += ptsAdjustment; + ptsTime &= 0x1FFFFFFFFL; + } + return ptsTime; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(ptsTime); + } + + public static final Creator CREATOR = + new Creator() { + + @Override + public TimeSignalCommand createFromParcel(Parcel in) { + return new TimeSignalCommand(in.readLong()); + } + + @Override + public TimeSignalCommand[] newArray(int size) { + return new TimeSignalCommand[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java b/library/src/main/java/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java index 77b49f2be0..f97d4a1542 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.source; -import static com.google.android.exoplayer2.C.usToMs; - import android.os.Handler; import android.os.SystemClock; import com.google.android.exoplayer2.C; @@ -174,10 +172,21 @@ public interface AdaptiveMediaSourceEventListener { private final Handler handler; private final AdaptiveMediaSourceEventListener listener; + private final long mediaTimeOffsetMs; public EventDispatcher(Handler handler, AdaptiveMediaSourceEventListener listener) { + this(handler, listener, 0); + } + + public EventDispatcher(Handler handler, AdaptiveMediaSourceEventListener listener, + long mediaTimeOffsetMs) { this.handler = listener != null ? Assertions.checkNotNull(handler) : null; this.listener = listener; + this.mediaTimeOffsetMs = mediaTimeOffsetMs; + } + + public EventDispatcher copyWithMediaTimeOffsetMs(long mediaTimeOffsetMs) { + return new EventDispatcher(handler, listener, mediaTimeOffsetMs); } public void loadStarted(DataSpec dataSpec, int dataType, long elapsedRealtimeMs) { @@ -193,8 +202,8 @@ public interface AdaptiveMediaSourceEventListener { @Override public void run() { listener.onLoadStarted(dataSpec, dataType, trackType, trackFormat, trackSelectionReason, - trackSelectionData, usToMs(mediaStartTimeUs), usToMs(mediaEndTimeUs), - elapsedRealtimeMs); + trackSelectionData, adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs), elapsedRealtimeMs); } }); } @@ -215,8 +224,8 @@ public interface AdaptiveMediaSourceEventListener { @Override public void run() { listener.onLoadCompleted(dataSpec, dataType, trackType, trackFormat, - trackSelectionReason, trackSelectionData, usToMs(mediaStartTimeUs), - usToMs(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs, bytesLoaded); + trackSelectionReason, trackSelectionData, adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs, bytesLoaded); } }); } @@ -237,8 +246,8 @@ public interface AdaptiveMediaSourceEventListener { @Override public void run() { listener.onLoadCanceled(dataSpec, dataType, trackType, trackFormat, - trackSelectionReason, trackSelectionData, usToMs(mediaStartTimeUs), - usToMs(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs, bytesLoaded); + trackSelectionReason, trackSelectionData, adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs, bytesLoaded); } }); } @@ -261,8 +270,9 @@ public interface AdaptiveMediaSourceEventListener { @Override public void run() { listener.onLoadError(dataSpec, dataType, trackType, trackFormat, trackSelectionReason, - trackSelectionData, usToMs(mediaStartTimeUs), usToMs(mediaEndTimeUs), - elapsedRealtimeMs, loadDurationMs, bytesLoaded, error, wasCanceled); + trackSelectionData, adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs, bytesLoaded, + error, wasCanceled); } }); } @@ -274,8 +284,8 @@ public interface AdaptiveMediaSourceEventListener { handler.post(new Runnable() { @Override public void run() { - listener.onUpstreamDiscarded(trackType, usToMs(mediaStartTimeUs), - usToMs(mediaEndTimeUs)); + listener.onUpstreamDiscarded(trackType, adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs)); } }); } @@ -289,12 +299,17 @@ public interface AdaptiveMediaSourceEventListener { @Override public void run() { listener.onDownstreamFormatChanged(trackType, trackFormat, trackSelectionReason, - trackSelectionData, usToMs(mediaTimeUs)); + trackSelectionData, adjustMediaTime(mediaTimeUs)); } }); } } + private long adjustMediaTime(long mediaTimeUs) { + long mediaTimeMs = C.usToMs(mediaTimeUs); + return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; + } + } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 3b743c5fda..68552c99ed 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import android.util.Pair; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.Util; @@ -53,12 +54,12 @@ public final class ConcatenatingMediaSource implements MediaSource { } @Override - public void prepareSource(Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { this.listener = listener; for (int i = 0; i < mediaSources.length; i++) { if (!duplicateFlags[i]) { final int index = i; - mediaSources[i].prepareSource(new Listener() { + mediaSources[i].prepareSource(player, false, new Listener() { @Override public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { handleSourceInfoRefreshed(index, timeline, manifest); @@ -171,11 +172,13 @@ public final class ConcatenatingMediaSource implements MediaSource { } @Override - public Window getWindow(int windowIndex, Window window, boolean setIds) { + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { int sourceIndex = getSourceIndexForWindow(windowIndex); int firstWindowIndexInSource = getFirstWindowIndexInSource(sourceIndex); int firstPeriodIndexInSource = getFirstPeriodIndexInSource(sourceIndex); - timelines[sourceIndex].getWindow(windowIndex - firstWindowIndexInSource, window, setIds); + timelines[sourceIndex].getWindow(windowIndex - firstWindowIndexInSource, window, setIds, + defaultPositionProjectionUs); window.firstPeriodIndex += firstPeriodIndexInSource; window.lastPeriodIndex += firstPeriodIndexInSource; return window; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java index 27bd1f677f..0b7190d382 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java @@ -39,7 +39,7 @@ import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.Loader.Loadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ConditionVariable; -import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.util.MimeTypes; import java.io.EOFException; import java.io.IOException; @@ -81,6 +81,8 @@ import java.io.IOException; private TrackGroupArray tracks; private long durationUs; private boolean[] trackEnabledStates; + private boolean[] trackIsAudioVideoFlags; + private boolean haveAudioVideoTracks; private long length; private long lastSeekPositionUs; @@ -229,7 +231,7 @@ import java.io.IOException; @Override public boolean continueLoading(long playbackPositionUs) { - if (loadingFinished) { + if (loadingFinished || (prepared && enabledTrackCount == 0)) { return false; } boolean continuedLoading = loadCondition.open(); @@ -260,11 +262,23 @@ import java.io.IOException; return C.TIME_END_OF_SOURCE; } else if (isPendingReset()) { return pendingResetPositionUs; - } else { - long largestQueuedTimestampUs = getLargestQueuedTimestampUs(); - return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs - : largestQueuedTimestampUs; } + long largestQueuedTimestampUs; + if (haveAudioVideoTracks) { + // Ignore non-AV tracks, which may be sparse or poorly interleaved. + largestQueuedTimestampUs = Long.MAX_VALUE; + int trackCount = sampleQueues.size(); + for (int i = 0; i < trackCount; i++) { + if (trackIsAudioVideoFlags[i]) { + largestQueuedTimestampUs = Math.min(largestQueuedTimestampUs, + sampleQueues.valueAt(i).getLargestQueuedTimestampUs()); + } + } + } else { + largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + } + return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs + : largestQueuedTimestampUs; } @Override @@ -405,10 +419,16 @@ import java.io.IOException; } loadCondition.close(); TrackGroup[] trackArray = new TrackGroup[trackCount]; + trackIsAudioVideoFlags = new boolean[trackCount]; trackEnabledStates = new boolean[trackCount]; durationUs = seekMap.getDurationUs(); for (int i = 0; i < trackCount; i++) { - trackArray[i] = new TrackGroup(sampleQueues.valueAt(i).getUpstreamFormat()); + Format trackFormat = sampleQueues.valueAt(i).getUpstreamFormat(); + trackArray[i] = new TrackGroup(trackFormat); + String mimeType = trackFormat.sampleMimeType; + boolean isAudioVideo = MimeTypes.isVideo(mimeType) || MimeTypes.isAudio(mimeType); + trackIsAudioVideoFlags[i] = isAudioVideo; + haveAudioVideoTracks |= isAudioVideo; } tracks = new TrackGroupArray(trackArray); prepared = true; @@ -433,7 +453,7 @@ import java.io.IOException; pendingResetPositionUs = C.TIME_UNSET; return; } - loadable.setLoadPosition(seekMap.getPosition(pendingResetPositionUs)); + loadable.setLoadPosition(seekMap.getPosition(pendingResetPositionUs), pendingResetPositionUs); pendingResetPositionUs = C.TIME_UNSET; } extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); @@ -466,7 +486,7 @@ import java.io.IOException; for (int i = 0; i < trackCount; i++) { sampleQueues.valueAt(i).reset(!prepared || trackEnabledStates[i]); } - loadable.setLoadPosition(0); + loadable.setLoadPosition(0, 0); } } @@ -558,6 +578,7 @@ import java.io.IOException; private volatile boolean loadCanceled; private boolean pendingExtractorSeek; + private long seekTimeUs; private long length; public ExtractingLoadable(Uri uri, DataSource dataSource, ExtractorHolder extractorHolder, @@ -571,8 +592,9 @@ import java.io.IOException; this.length = C.LENGTH_UNSET; } - public void setLoadPosition(long position) { + public void setLoadPosition(long position, long timeUs) { positionHolder.position = position; + seekTimeUs = timeUs; pendingExtractorSeek = true; } @@ -593,15 +615,14 @@ import java.io.IOException; ExtractorInput input = null; try { long position = positionHolder.position; - length = dataSource.open( - new DataSpec(uri, position, C.LENGTH_UNSET, Util.sha1(uri.toString()))); + length = dataSource.open(new DataSpec(uri, position, C.LENGTH_UNSET, null)); if (length != C.LENGTH_UNSET) { length += position; } input = new DefaultExtractorInput(dataSource, position, length); Extractor extractor = extractorHolder.selectExtractor(input); if (pendingExtractorSeek) { - extractor.seek(position); + extractor.seek(position, seekTimeUs); pendingExtractorSeek = false; } while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { diff --git a/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java index bf795241bc..559d241598 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import android.net.Uri; import android.os.Handler; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; @@ -135,7 +136,7 @@ public final class ExtractorMediaSource implements MediaSource, MediaSource.List } @Override - public void prepareSource(MediaSource.Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { sourceListener = listener; timeline = new SinglePeriodTimeline(C.TIME_UNSET, false); listener.onSourceInfoRefreshed(timeline, null); diff --git a/library/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index 21455ed89d..d893d60262 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import android.util.Log; import android.util.Pair; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.Assertions; @@ -59,8 +60,8 @@ public final class LoopingMediaSource implements MediaSource { } @Override - public void prepareSource(final Listener listener) { - childSource.prepareSource(new Listener() { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, final Listener listener) { + childSource.prepareSource(player, false, new Listener() { @Override public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { childPeriodCount = timeline.getPeriodCount(); @@ -118,8 +119,10 @@ public final class LoopingMediaSource implements MediaSource { } @Override - public Window getWindow(int windowIndex, Window window, boolean setIds) { - childTimeline.getWindow(windowIndex % childWindowCount, window, setIds); + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { + childTimeline.getWindow(windowIndex % childWindowCount, window, setIds, + defaultPositionProjectionUs); int periodIndexOffset = (windowIndex / childWindowCount) * childPeriodCount; window.firstPeriodIndex += periodIndexOffset; window.lastPeriodIndex += periodIndexOffset; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/MediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 709a92cbf5..f013e790f7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import java.io.IOException; @@ -42,9 +43,14 @@ public interface MediaSource { /** * Starts preparation of the source. * + * @param player The player for which this source is being prepared. + * @param isTopLevelSource Whether this source has been passed directly to + * {@link ExoPlayer#prepare(MediaSource)} or + * {@link ExoPlayer#prepare(MediaSource, boolean, boolean)}. If {@code false}, this source is + * being prepared by another source (e.g. {@link ConcatenatingMediaSource}) for composition. * @param listener The listener for source events. */ - void prepareSource(Listener listener); + void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener); /** * Throws any pending error encountered while loading or refreshing source information. @@ -52,7 +58,9 @@ public interface MediaSource { void maybeThrowSourceInfoRefreshError() throws IOException; /** - * Returns a {@link MediaPeriod} corresponding to the period at the specified index. + * Returns a new {@link MediaPeriod} corresponding to the period at the specified {@code index}. + * This method may be called multiple times with the same index without an intervening call to + * {@link #releasePeriod(MediaPeriod)}. * * @param index The index of the period. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. diff --git a/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java index cfab4b14aa..10c56e5576 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java @@ -149,9 +149,9 @@ import java.util.IdentityHashMap; } // It must be possible to seek enabled periods to the new position, if there is one. if (positionUs != C.TIME_UNSET) { - for (int i = 0; i < enabledPeriods.length; i++) { - if (enabledPeriods[i] != periods[0] - && enabledPeriods[i].seekToUs(positionUs) != positionUs) { + for (MediaPeriod enabledPeriod : enabledPeriods) { + if (enabledPeriod != periods[0] + && enabledPeriod.seekToUs(positionUs) != positionUs) { throw new IllegalStateException("Children seeked to different positions"); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java index 7d5f78c1cd..417483cebc 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source; import android.support.annotation.IntDef; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import java.io.IOException; @@ -92,11 +93,11 @@ public final class MergingMediaSource implements MediaSource { } @Override - public void prepareSource(final Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { this.listener = listener; for (int i = 0; i < mediaSources.length; i++) { final int sourceIndex = i; - mediaSources[sourceIndex].prepareSource(new Listener() { + mediaSources[sourceIndex].prepareSource(player, false, new Listener() { @Override public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { handleSourceInfoRefreshed(sourceIndex, timeline, manifest); diff --git a/library/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/library/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java index f298d04432..ae367ef14c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -74,9 +74,18 @@ public final class SinglePeriodTimeline extends Timeline { } @Override - public Window getWindow(int windowIndex, Window window, boolean setIds) { + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { Assertions.checkIndex(windowIndex, 0, 1); Object id = setIds ? ID : null; + long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs; + if (isDynamic) { + windowDefaultStartPositionUs += defaultPositionProjectionUs; + if (windowDefaultStartPositionUs > windowDurationUs) { + // The projection takes us beyond the end of the live window. + windowDefaultStartPositionUs = C.TIME_UNSET; + } + } return window.set(id, C.TIME_UNSET, C.TIME_UNSET, isSeekable, isDynamic, windowDefaultStartPositionUs, windowDurationUs, 0, 0, windowPositionInPeriodUs); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 385bebfb88..f6ee84a6f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import android.net.Uri; import android.os.Handler; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; @@ -84,7 +85,7 @@ public final class SingleSampleMediaSource implements MediaSource { // MediaSource implementation. @Override - public void prepareSource(MediaSource.Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { listener.onSourceInfoRefreshed(timeline, null); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/TrackGroupArray.java b/library/src/main/java/com/google/android/exoplayer2/source/TrackGroupArray.java index d562ec43e1..394cec891b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/TrackGroupArray.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/TrackGroupArray.java @@ -23,6 +23,11 @@ import java.util.Arrays; */ public final class TrackGroupArray { + /** + * The empty array. + */ + public static final TrackGroupArray EMPTY = new TrackGroupArray(); + /** * The number of groups in the array. Greater than or equal to zero. */ diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java index b9aa098b9d..ed76a505ea 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -92,7 +92,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput extractor.init(this); extractorInitialized = true; } else { - extractor.seek(0); + extractor.seek(0, 0); if (resendFormatOnInit && sentFormat != null) { trackOutput.format(sentFormat); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index bb2f9b214b..6de7c6ec01 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -251,7 +251,7 @@ public class ChunkSampleStream implements SampleStream, S @Override public boolean continueLoading(long positionUs) { - if (loader.isLoading()) { + if (loadingFinished || loader.isLoading()) { return false; } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkedTrackBlacklistUtil.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkedTrackBlacklistUtil.java index 6c085418bd..38e0c0d51f 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkedTrackBlacklistUtil.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkedTrackBlacklistUtil.java @@ -51,9 +51,9 @@ public final class ChunkedTrackBlacklistUtil { /** * Blacklists {@code trackSelectionIndex} in {@code trackSelection} for - * {@code blacklistDurationMs} if {@code e} is an {@link InvalidResponseCodeException} with - * {@link InvalidResponseCodeException#responseCode} equal to 404 or 410. Else does nothing. Note - * that blacklisting will fail if the track is the only non-blacklisted track in the selection. + * {@code blacklistDurationMs} if calling {@link #shouldBlacklist(Exception)} for {@code e} + * returns true. Else does nothing. Note that blacklisting will fail if the track is the only + * non-blacklisted track in the selection. * * @param trackSelection The track selection. * @param trackSelectionIndex The index in the selection to consider blacklisting. @@ -63,24 +63,33 @@ public final class ChunkedTrackBlacklistUtil { */ public static boolean maybeBlacklistTrack(TrackSelection trackSelection, int trackSelectionIndex, Exception e, long blacklistDurationMs) { - if (trackSelection.length() == 1) { - // Blacklisting won't ever work if there's only one track in the selection. - return false; - } - if (e instanceof InvalidResponseCodeException) { - InvalidResponseCodeException responseCodeException = (InvalidResponseCodeException) e; - int responseCode = responseCodeException.responseCode; - if (responseCode == 404 || responseCode == 410) { - boolean blacklisted = trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs); - if (blacklisted) { - Log.w(TAG, "Blacklisted: duration=" + blacklistDurationMs + ", responseCode=" - + responseCode + ", format=" + trackSelection.getFormat(trackSelectionIndex)); - } else { - Log.w(TAG, "Blacklisting failed (cannot blacklist last enabled track): responseCode=" - + responseCode + ", format=" + trackSelection.getFormat(trackSelectionIndex)); - } - return blacklisted; + if (shouldBlacklist(e)) { + boolean blacklisted = trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs); + int responseCode = ((InvalidResponseCodeException) e).responseCode; + if (blacklisted) { + Log.w(TAG, "Blacklisted: duration=" + blacklistDurationMs + ", responseCode=" + + responseCode + ", format=" + trackSelection.getFormat(trackSelectionIndex)); + } else { + Log.w(TAG, "Blacklisting failed (cannot blacklist last enabled track): responseCode=" + + responseCode + ", format=" + trackSelection.getFormat(trackSelectionIndex)); } + return blacklisted; + } + return false; + } + + /** + * Returns whether a loading error is an {@link InvalidResponseCodeException} with + * {@link InvalidResponseCodeException#responseCode} equal to 404 or 410. + * + * @param e The loading error. + * @return Wheter the loading error is an {@link InvalidResponseCodeException} with + * {@link InvalidResponseCodeException#responseCode} equal to 404 or 410. + */ + public static boolean shouldBlacklist(Exception e) { + if (e instanceof InvalidResponseCodeException) { + int responseCode = ((InvalidResponseCodeException) e).responseCode; + return responseCode == 404 || responseCode == 410; } return false; } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 130dddc5eb..a5af3cc42f 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -32,8 +32,9 @@ import java.io.IOException; */ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMetadataOutput { - private final ChunkExtractorWrapper extractorWrapper; + private final int chunkCount; private final long sampleOffsetUs; + private final ChunkExtractorWrapper extractorWrapper; private final Format sampleFormat; private volatile int bytesLoaded; @@ -49,6 +50,9 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMe * @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 chunkCount The number of chunks in the underlying media that are spanned by this + * instance. Normally equal to one, but may be larger if multiple chunks as defined by the + * underlying media are being merged into a single load. * @param sampleOffsetUs An offset to add to the sample timestamps parsed by the extractor. * @param extractorWrapper A wrapped extractor to use for parsing the data. * @param sampleFormat The {@link Format} of the samples in the chunk, if known. May be null if @@ -56,15 +60,21 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMe */ public ContainerMediaChunk(DataSource dataSource, DataSpec dataSpec, Format trackFormat, int trackSelectionReason, Object trackSelectionData, long startTimeUs, long endTimeUs, - int chunkIndex, long sampleOffsetUs, ChunkExtractorWrapper extractorWrapper, + int chunkIndex, int chunkCount, long sampleOffsetUs, ChunkExtractorWrapper extractorWrapper, Format sampleFormat) { super(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, startTimeUs, endTimeUs, chunkIndex); - this.extractorWrapper = extractorWrapper; + this.chunkCount = chunkCount; this.sampleOffsetUs = sampleOffsetUs; + this.extractorWrapper = extractorWrapper; this.sampleFormat = sampleFormat; } + @Override + public int getNextChunkIndex() { + return chunkIndex + chunkCount; + } + @Override public boolean isLoadCompleted() { return loadCompleted; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/MediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/MediaChunk.java index d3e211c09f..3a02884fff 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/MediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/MediaChunk.java @@ -53,7 +53,7 @@ public abstract class MediaChunk extends Chunk { /** * Returns the next chunk index. */ - public final int getNextChunkIndex() { + public int getNextChunkIndex() { return chunkIndex + 1; } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 766f1e0ebf..99845c057e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -21,6 +21,7 @@ import android.os.SystemClock; import android.util.Log; import android.util.SparseArray; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; @@ -29,11 +30,11 @@ import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.dash.manifest.Period; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; @@ -81,6 +82,7 @@ public final class DashMediaSource implements MediaSource { private static final String TAG = "DashMediaSource"; + private final boolean sideloadedManifest; private final DataSource.Factory manifestDataSourceFactory; private final DashChunkSource.Factory chunkSourceFactory; private final int minLoadableRetryCount; @@ -93,9 +95,10 @@ public final class DashMediaSource implements MediaSource { private final Runnable refreshManifestRunnable; private final Runnable simulateManifestRefreshRunnable; - private MediaSource.Listener sourceListener; + private Listener sourceListener; private DataSource dataSource; private Loader loader; + private LoaderErrorThrower loaderErrorThrower; private Uri manifestUri; private long manifestLoadStartTimestamp; @@ -106,6 +109,47 @@ public final class DashMediaSource implements MediaSource { private int firstPeriodId; + /** + * Constructs an instance to play a given {@link DashManifest}, which must be static. + * + * @param manifest The manifest. {@link DashManifest#dynamic} must be false. + * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public DashMediaSource(DashManifest manifest, DashChunkSource.Factory chunkSourceFactory, + Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { + this(manifest, chunkSourceFactory, DEFAULT_MIN_LOADABLE_RETRY_COUNT, eventHandler, + eventListener); + } + + /** + * Constructs an instance to play a given {@link DashManifest}, which must be static. + * + * @param manifest The manifest. {@link DashManifest#dynamic} must be false. + * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public DashMediaSource(DashManifest manifest, DashChunkSource.Factory chunkSourceFactory, + int minLoadableRetryCount, Handler eventHandler, AdaptiveMediaSourceEventListener + eventListener) { + this(manifest, null, null, null, chunkSourceFactory, minLoadableRetryCount, + DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS, eventHandler, eventListener); + } + + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or + * static. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ public DashMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, DashChunkSource.Factory chunkSourceFactory, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { @@ -114,32 +158,91 @@ public final class DashMediaSource implements MediaSource { eventHandler, eventListener); } + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or + * static. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the + * default start position should precede the end of the live window. Use + * {@link #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by + * the manifest, if present. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ public DashMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, DashChunkSource.Factory chunkSourceFactory, int minLoadableRetryCount, long livePresentationDelayMs, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { + this(manifestUri, manifestDataSourceFactory, new DashManifestParser(), chunkSourceFactory, + minLoadableRetryCount, livePresentationDelayMs, eventHandler, eventListener); + } + + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or + * static. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param manifestParser A parser for loaded manifest data. + * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the + * default start position should precede the end of the live window. Use + * {@link #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by + * the manifest, if present. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public DashMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, + DashManifestParser manifestParser, DashChunkSource.Factory chunkSourceFactory, + int minLoadableRetryCount, long livePresentationDelayMs, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener) { + this(null, manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, + minLoadableRetryCount, livePresentationDelayMs, eventHandler, eventListener); + } + + private DashMediaSource(DashManifest manifest, Uri manifestUri, + DataSource.Factory manifestDataSourceFactory, DashManifestParser manifestParser, + DashChunkSource.Factory chunkSourceFactory, int minLoadableRetryCount, + long livePresentationDelayMs, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener) { + this.manifest = manifest; this.manifestUri = manifestUri; this.manifestDataSourceFactory = manifestDataSourceFactory; + this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; this.minLoadableRetryCount = minLoadableRetryCount; this.livePresentationDelayMs = livePresentationDelayMs; + sideloadedManifest = manifest != null; eventDispatcher = new EventDispatcher(eventHandler, eventListener); - manifestParser = new DashManifestParser(generateContentId()); - manifestCallback = new ManifestCallback(); manifestUriLock = new Object(); periodsById = new SparseArray<>(); - refreshManifestRunnable = new Runnable() { - @Override - public void run() { - startLoadingManifest(); - } - }; - simulateManifestRefreshRunnable = new Runnable() { - @Override - public void run() { - processManifest(); - } - }; + if (sideloadedManifest) { + Assertions.checkState(!manifest.dynamic); + manifestCallback = null; + refreshManifestRunnable = null; + simulateManifestRefreshRunnable = null; + } else { + manifestCallback = new ManifestCallback(); + refreshManifestRunnable = new Runnable() { + @Override + public void run() { + startLoadingManifest(); + } + }; + simulateManifestRefreshRunnable = new Runnable() { + @Override + public void run() { + processManifest(false); + } + }; + } } /** @@ -156,24 +259,32 @@ public final class DashMediaSource implements MediaSource { // MediaSource implementation. @Override - public void prepareSource(MediaSource.Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { sourceListener = listener; - dataSource = manifestDataSourceFactory.createDataSource(); - loader = new Loader("Loader:DashMediaSource"); - handler = new Handler(); - startLoadingManifest(); + if (sideloadedManifest) { + loaderErrorThrower = new LoaderErrorThrower.Dummy(); + processManifest(false); + } else { + dataSource = manifestDataSourceFactory.createDataSource(); + loader = new Loader("Loader:DashMediaSource"); + loaderErrorThrower = loader; + handler = new Handler(); + startLoadingManifest(); + } } @Override public void maybeThrowSourceInfoRefreshError() throws IOException { - loader.maybeThrowError(); + loaderErrorThrower.maybeThrowError(); } @Override - public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { - DashMediaPeriod mediaPeriod = new DashMediaPeriod(firstPeriodId + index, manifest, index, - chunkSourceFactory, minLoadableRetryCount, eventDispatcher, elapsedRealtimeOffsetMs, loader, - allocator); + public MediaPeriod createPeriod(int periodIndex, Allocator allocator, long positionUs) { + EventDispatcher periodEventDispatcher = eventDispatcher.copyWithMediaTimeOffsetMs( + manifest.getPeriod(periodIndex).startMs); + DashMediaPeriod mediaPeriod = new DashMediaPeriod(firstPeriodId + periodIndex, manifest, + periodIndex, chunkSourceFactory, minLoadableRetryCount, periodEventDispatcher, + elapsedRealtimeOffsetMs, loaderErrorThrower, allocator); periodsById.put(mediaPeriod.id, mediaPeriod); return mediaPeriod; } @@ -188,6 +299,7 @@ public final class DashMediaSource implements MediaSource { @Override public void releaseSource() { dataSource = null; + loaderErrorThrower = null; if (loader != null) { loader.release(); loader = null; @@ -246,11 +358,11 @@ public final class DashMediaSource implements MediaSource { if (manifest.utcTiming != null) { resolveUtcTimingElement(manifest.utcTiming); } else { - processManifestAndScheduleRefresh(); + processManifest(true); } } else { firstPeriodId += removedPeriodCount; - processManifestAndScheduleRefresh(); + processManifest(true); } } @@ -313,8 +425,8 @@ public final class DashMediaSource implements MediaSource { try { long utcTimestamp = Util.parseXsDateTime(timingElement.value); onUtcTimestampResolved(utcTimestamp - manifestLoadEndTimestamp); - } catch (ParseException e) { - onUtcTimestampResolutionError(new ParserException(e)); + } catch (ParserException e) { + onUtcTimestampResolutionError(e); } } @@ -326,21 +438,16 @@ public final class DashMediaSource implements MediaSource { private void onUtcTimestampResolved(long elapsedRealtimeOffsetMs) { this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs; - processManifestAndScheduleRefresh(); + processManifest(true); } private void onUtcTimestampResolutionError(IOException error) { Log.e(TAG, "Failed to resolve UtcTiming element.", error); // Be optimistic and continue in the hope that the device clock is correct. - processManifestAndScheduleRefresh(); + processManifest(true); } - private void processManifestAndScheduleRefresh() { - processManifest(); - scheduleManifestRefresh(); - } - - private void processManifest() { + private void processManifest(boolean scheduleRefresh) { // Update any periods. for (int i = 0; i < periodsById.size(); i++) { int id = periodsById.keyAt(i); @@ -350,9 +457,8 @@ public final class DashMediaSource implements MediaSource { // This period has been removed from the manifest so it doesn't need to be updated. } } - // Remove any pending simulated updates. - handler.removeCallbacks(simulateManifestRefreshRunnable); // Update the window. + boolean windowChangingImplicitly = false; int lastPeriodIndex = manifest.getPeriodCount() - 1; PeriodSeekInfo firstPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(manifest.getPeriod(0), manifest.getPeriodDurationUs(0)); @@ -383,8 +489,7 @@ public final class DashMediaSource implements MediaSource { currentStartTimeUs = manifest.getPeriodDurationUs(0); } } - // The window is changing implicitly. Post a simulated manifest refresh to update it. - handler.postDelayed(simulateManifestRefreshRunnable, NOTIFY_MANIFEST_INTERVAL_MS); + windowChangingImplicitly = true; } long windowDurationUs = currentEndTimeUs - currentStartTimeUs; for (int i = 0; i < manifest.getPeriodCount() - 1; i++) { @@ -398,35 +503,13 @@ public final class DashMediaSource implements MediaSource { ? manifest.suggestedPresentationDelay : DEFAULT_LIVE_PRESENTATION_DELAY_FIXED_MS; } // Snap the default position to the start of the segment containing it. - long defaultStartPositionUs = windowDurationUs - C.msToUs(presentationDelayForManifestMs); - if (defaultStartPositionUs < MIN_LIVE_DEFAULT_START_POSITION_US) { + windowDefaultStartPositionUs = windowDurationUs - C.msToUs(presentationDelayForManifestMs); + if (windowDefaultStartPositionUs < MIN_LIVE_DEFAULT_START_POSITION_US) { // The default start position is too close to the start of the live window. Set it to the // minimum default start position provided the window is at least twice as big. Else set // it to the middle of the window. - defaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US, windowDurationUs / 2); - } - - int periodIndex = 0; - long defaultStartPositionInPeriodUs = currentStartTimeUs + defaultStartPositionUs; - long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); - while (periodIndex < manifest.getPeriodCount() - 1 - && defaultStartPositionInPeriodUs >= periodDurationUs) { - defaultStartPositionInPeriodUs -= periodDurationUs; - periodIndex++; - periodDurationUs = manifest.getPeriodDurationUs(periodIndex); - } - Period period = manifest.getPeriod(periodIndex); - int videoAdaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_VIDEO); - if (videoAdaptationSetIndex != C.INDEX_UNSET) { - // If there are multiple video adaptation sets with unaligned segments, the initial time may - // not correspond to the start of a segment in both, but this is an edge case. - DashSegmentIndex index = - period.adaptationSets.get(videoAdaptationSetIndex).representations.get(0).getIndex(); - int segmentNum = index.getSegmentNum(defaultStartPositionInPeriodUs, periodDurationUs); - windowDefaultStartPositionUs = - defaultStartPositionUs - defaultStartPositionInPeriodUs + index.getTimeUs(segmentNum); - } else { - windowDefaultStartPositionUs = defaultStartPositionUs; + windowDefaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US, + windowDurationUs / 2); } } long windowStartTimeMs = manifest.availabilityStartTime @@ -435,6 +518,19 @@ public final class DashMediaSource implements MediaSource { firstPeriodId, currentStartTimeUs, windowDurationUs, windowDefaultStartPositionUs, manifest); sourceListener.onSourceInfoRefreshed(timeline, manifest); + + if (!sideloadedManifest) { + // Remove any pending simulated refresh. + handler.removeCallbacks(simulateManifestRefreshRunnable); + // If the window is changing implicitly, post a simulated manifest refresh to update it. + if (windowChangingImplicitly) { + handler.postDelayed(simulateManifestRefreshRunnable, NOTIFY_MANIFEST_INTERVAL_MS); + } + // Schedule an explicit refresh if needed. + if (scheduleRefresh) { + scheduleManifestRefresh(); + } + } } private void scheduleManifestRefresh() { @@ -468,10 +564,6 @@ public final class DashMediaSource implements MediaSource { } } - private String generateContentId() { - return Util.sha1(manifestUri.toString()); - } - private static final class PeriodSeekInfo { public static PeriodSeekInfo createPeriodSeekInfo( @@ -559,8 +651,11 @@ public final class DashMediaSource implements MediaSource { } @Override - public Window getWindow(int windowIndex, Window window, boolean setIdentifier) { + public Window getWindow(int windowIndex, Window window, boolean setIdentifier, + long defaultPositionProjectionUs) { Assertions.checkIndex(windowIndex, 0, 1); + long windowDefaultStartPositionUs = getAdjustedWindowDefaultStartPositionUs( + defaultPositionProjectionUs); return window.set(null, presentationStartTimeMs, windowStartTimeMs, true /* isSeekable */, manifest.dynamic, windowDefaultStartPositionUs, windowDurationUs, 0, manifest.getPeriodCount() - 1, offsetInFirstPeriodUs); @@ -576,6 +671,48 @@ public final class DashMediaSource implements MediaSource { ? C.INDEX_UNSET : (periodId - firstPeriodId); } + private long getAdjustedWindowDefaultStartPositionUs(long defaultPositionProjectionUs) { + long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs; + if (!manifest.dynamic) { + return windowDefaultStartPositionUs; + } + if (defaultPositionProjectionUs > 0) { + windowDefaultStartPositionUs += defaultPositionProjectionUs; + if (windowDefaultStartPositionUs > windowDurationUs) { + // The projection takes us beyond the end of the live window. + return C.TIME_UNSET; + } + } + // Attempt to snap to the start of the corresponding video segment. + int periodIndex = 0; + long defaultStartPositionInPeriodUs = offsetInFirstPeriodUs + windowDefaultStartPositionUs; + long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); + while (periodIndex < manifest.getPeriodCount() - 1 + && defaultStartPositionInPeriodUs >= periodDurationUs) { + defaultStartPositionInPeriodUs -= periodDurationUs; + periodIndex++; + periodDurationUs = manifest.getPeriodDurationUs(periodIndex); + } + com.google.android.exoplayer2.source.dash.manifest.Period period = + manifest.getPeriod(periodIndex); + int videoAdaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_VIDEO); + if (videoAdaptationSetIndex == C.INDEX_UNSET) { + // No video adaptation set for snapping. + return windowDefaultStartPositionUs; + } + // If there are multiple video adaptation sets with unaligned segments, the initial time may + // not correspond to the start of a segment in both, but this is an edge case. + DashSegmentIndex snapIndex = period.adaptationSets.get(videoAdaptationSetIndex) + .representations.get(0).getIndex(); + if (snapIndex == null) { + // Video adaptation set does not include an index for snapping. + return windowDefaultStartPositionUs; + } + int segmentNum = snapIndex.getSegmentNum(defaultStartPositionInPeriodUs, periodDurationUs); + return windowDefaultStartPositionUs + snapIndex.getTimeUs(segmentNum) + - defaultStartPositionInPeriodUs; + } + } private final class ManifestCallback implements @@ -628,11 +765,7 @@ public final class DashMediaSource implements MediaSource { @Override public Long parse(Uri uri, InputStream inputStream) throws IOException { String firstLine = new BufferedReader(new InputStreamReader(inputStream)).readLine(); - try { - return Util.parseXsDateTime(firstLine); - } catch (ParseException e) { - throw new ParserException(e); - } + return Util.parseXsDateTime(firstLine); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java index 716c9ad844..9e48bc2c79 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java @@ -25,7 +25,6 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; /* package */ final class DashWrappingSegmentIndex implements DashSegmentIndex { private final ChunkIndex chunkIndex; - private final String uri; /** * @param chunkIndex The {@link ChunkIndex} to wrap. @@ -33,7 +32,6 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; */ public DashWrappingSegmentIndex(ChunkIndex chunkIndex, String uri) { this.chunkIndex = chunkIndex; - this.uri = uri; } @Override @@ -58,7 +56,7 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; @Override public RangedUri getSegmentUrl(int segmentNum) { - return new RangedUri(uri, null, chunkIndex.offsets[segmentNum], chunkIndex.sizes[segmentNum]); + return new RangedUri(null, chunkIndex.offsets[segmentNum], chunkIndex.sizes[segmentNum]); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index a7c7389b2b..0e3d127796 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash; +import android.net.Uri; import android.os.SystemClock; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -54,9 +55,15 @@ public class DefaultDashChunkSource implements DashChunkSource { public static final class Factory implements DashChunkSource.Factory { private final DataSource.Factory dataSourceFactory; + private final int maxSegmentsPerLoad; public Factory(DataSource.Factory dataSourceFactory) { + this(dataSourceFactory, 1); + } + + public Factory(DataSource.Factory dataSourceFactory, int maxSegmentsPerLoad) { this.dataSourceFactory = dataSourceFactory; + this.maxSegmentsPerLoad = maxSegmentsPerLoad; } @Override @@ -65,7 +72,8 @@ public class DefaultDashChunkSource implements DashChunkSource { TrackSelection trackSelection, long elapsedRealtimeOffsetMs) { DataSource dataSource = dataSourceFactory.createDataSource(); return new DefaultDashChunkSource(manifestLoaderErrorThrower, manifest, periodIndex, - adaptationSetIndex, trackSelection, dataSource, elapsedRealtimeOffsetMs); + adaptationSetIndex, trackSelection, dataSource, elapsedRealtimeOffsetMs, + maxSegmentsPerLoad); } } @@ -76,6 +84,7 @@ public class DefaultDashChunkSource implements DashChunkSource { private final RepresentationHolder[] representationHolders; private final DataSource dataSource; private final long elapsedRealtimeOffsetMs; + private final int maxSegmentsPerLoad; private DashManifest manifest; private int periodIndex; @@ -93,10 +102,13 @@ public class DefaultDashChunkSource implements DashChunkSource { * @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between * server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified * as the server's unix time minus the local elapsed time. If unknown, set to 0. + * @param maxSegmentsPerLoad The maximum number of segments to combine into a single request. + * Note that segments will only be combined if their {@link Uri}s are the same and if their + * data ranges are adjacent. */ public DefaultDashChunkSource(LoaderErrorThrower manifestLoaderErrorThrower, DashManifest manifest, int periodIndex, int adaptationSetIndex, TrackSelection trackSelection, - DataSource dataSource, long elapsedRealtimeOffsetMs) { + DataSource dataSource, long elapsedRealtimeOffsetMs, int maxSegmentsPerLoad) { this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.manifest = manifest; this.adaptationSetIndex = adaptationSetIndex; @@ -104,6 +116,7 @@ public class DefaultDashChunkSource implements DashChunkSource { this.dataSource = dataSource; this.periodIndex = periodIndex; this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs; + this.maxSegmentsPerLoad = maxSegmentsPerLoad; long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); List representations = getRepresentations(); @@ -219,9 +232,10 @@ public class DefaultDashChunkSource implements DashChunkSource { return; } + int maxSegmentCount = Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1); Chunk nextMediaChunk = newMediaChunk(representationHolder, dataSource, trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(), - trackSelection.getSelectionData(), sampleFormat, segmentNum); + trackSelection.getSelectionData(), sampleFormat, segmentNum, maxSegmentCount); out.chunk = nextMediaChunk; } @@ -260,7 +274,7 @@ public class DefaultDashChunkSource implements DashChunkSource { RepresentationHolder representationHolder = representationHolders[trackSelection.indexOf(chunk.trackFormat)]; int lastAvailableSegmentNum = representationHolder.getLastSegmentNum(); - if (((MediaChunk) chunk).chunkIndex >= lastAvailableSegmentNum) { + if (((MediaChunk) chunk).getNextChunkIndex() > lastAvailableSegmentNum) { missingLastSegment = true; return true; } @@ -284,44 +298,59 @@ public class DefaultDashChunkSource implements DashChunkSource { } } - private Chunk newInitializationChunk(RepresentationHolder representationHolder, + private static Chunk newInitializationChunk(RepresentationHolder representationHolder, DataSource dataSource, Format trackFormat, int trackSelectionReason, Object trackSelectionData, RangedUri initializationUri, RangedUri indexUri) { RangedUri requestUri; + String baseUrl = representationHolder.representation.baseUrl; if (initializationUri != null) { // It's common for initialization and index data to be stored adjacently. Attempt to merge // the two requests together to request both at once. - requestUri = initializationUri.attemptMerge(indexUri); + requestUri = initializationUri.attemptMerge(indexUri, baseUrl); if (requestUri == null) { requestUri = initializationUri; } } else { requestUri = indexUri; } - DataSpec dataSpec = new DataSpec(requestUri.getUri(), requestUri.start, requestUri.length, - representationHolder.representation.getCacheKey()); + DataSpec dataSpec = new DataSpec(requestUri.resolveUri(baseUrl), requestUri.start, + requestUri.length, representationHolder.representation.getCacheKey()); return new InitializationChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper); } - private Chunk newMediaChunk(RepresentationHolder representationHolder, DataSource dataSource, - Format trackFormat, int trackSelectionReason, - Object trackSelectionData, Format sampleFormat, int segmentNum) { + private static Chunk newMediaChunk(RepresentationHolder representationHolder, + DataSource dataSource, Format trackFormat, int trackSelectionReason, + Object trackSelectionData, Format sampleFormat, int firstSegmentNum, int maxSegmentCount) { Representation representation = representationHolder.representation; - long startTimeUs = representationHolder.getSegmentStartTimeUs(segmentNum); - long endTimeUs = representationHolder.getSegmentEndTimeUs(segmentNum); - RangedUri segmentUri = representationHolder.getSegmentUrl(segmentNum); - DataSpec dataSpec = new DataSpec(segmentUri.getUri(), segmentUri.start, segmentUri.length, - representation.getCacheKey()); - + long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); + RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); + String baseUrl = representation.baseUrl; if (representationHolder.extractorWrapper == null) { + long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); + DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl), + segmentUri.start, segmentUri.length, representation.getCacheKey()); return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, - trackSelectionData, startTimeUs, endTimeUs, segmentNum, trackFormat); + trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackFormat); } else { + int segmentCount = 1; + for (int i = 1; i < maxSegmentCount; i++) { + RangedUri nextSegmentUri = representationHolder.getSegmentUrl(firstSegmentNum + i); + RangedUri mergedSegmentUri = segmentUri.attemptMerge(nextSegmentUri, baseUrl); + if (mergedSegmentUri == null) { + // Unable to merge segment fetches because the URIs do not merge. + break; + } + segmentUri = mergedSegmentUri; + segmentCount++; + } + long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1); + DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl), + segmentUri.start, segmentUri.length, representation.getCacheKey()); long sampleOffsetUs = -representation.presentationTimeOffsetUs; return new ContainerMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, - trackSelectionData, startTimeUs, endTimeUs, segmentNum, sampleOffsetUs, - representationHolder.extractorWrapper, sampleFormat); + trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, segmentCount, + sampleOffsetUs, representationHolder.extractorWrapper, sampleFormat); } } @@ -348,7 +377,7 @@ public class DefaultDashChunkSource implements DashChunkSource { boolean resendFormatOnInit = false; Extractor extractor; if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { - extractor = new RawCcExtractor(); + extractor = new RawCcExtractor(representation.format); resendFormatOnInit = true; } else if (mimeTypeIsWebm(containerMimeType)) { extractor = new MatroskaExtractor(); diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index b2f0ae6f98..7e2ce0de1d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -38,7 +38,6 @@ import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.XmlPullParserUtil; import java.io.IOException; import java.io.InputStream; -import java.text.ParseException; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -59,6 +58,10 @@ public class DashManifestParser extends DefaultHandler private static final Pattern FRAME_RATE_PATTERN = Pattern.compile("(\\d+)(?:/(\\d+))?"); + private static final Pattern CEA_608_ACCESSIBILITY_PATTERN = Pattern.compile("CC([1-4])=.*"); + private static final Pattern CEA_708_ACCESSIBILITY_PATTERN = + Pattern.compile("([1-9]|[1-5][0-9]|6[0-3])=.*"); + private final String contentId; private final XmlPullParserFactory xmlParserFactory; @@ -94,13 +97,13 @@ public class DashManifestParser extends DefaultHandler "inputStream does not contain a valid media presentation description"); } return parseMediaPresentationDescription(xpp, uri.toString()); - } catch (XmlPullParserException | ParseException e) { + } catch (XmlPullParserException e) { throw new ParserException(e); } } protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, - String baseUrl) throws XmlPullParserException, IOException, ParseException { + String baseUrl) throws XmlPullParserException, IOException { long availabilityStartTime = parseDateTime(xpp, "availabilityStartTime", C.TIME_UNSET); long durationMs = parseDuration(xpp, "mediaPresentationDuration", C.TIME_UNSET); long minBufferTimeMs = parseDuration(xpp, "minBufferTime", C.TIME_UNSET); @@ -205,11 +208,11 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "AdaptationSet")) { adaptationSets.add(parseAdaptationSet(xpp, baseUrl, segmentBase)); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { - segmentBase = parseSegmentBase(xpp, baseUrl, null); + segmentBase = parseSegmentBase(xpp, null); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, baseUrl, null); + segmentBase = parseSegmentList(xpp, null); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { - segmentBase = parseSegmentTemplate(xpp, baseUrl, null); + segmentBase = parseSegmentTemplate(xpp, null); } } while (!XmlPullParserUtil.isEndTag(xpp, "Period")); @@ -235,6 +238,7 @@ public class DashManifestParser extends DefaultHandler int audioChannels = Format.NO_VALUE; int audioSamplingRate = parseInt(xpp, "audioSamplingRate", Format.NO_VALUE); String language = xpp.getAttributeValue(null, "lang"); + int accessibilityChannel = Format.NO_VALUE; ArrayList drmSchemeDatas = new ArrayList<>(); List representationInfos = new ArrayList<>(); @@ -256,18 +260,21 @@ public class DashManifestParser extends DefaultHandler contentType = checkContentTypeConsistency(contentType, parseContentType(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { RepresentationInfo representationInfo = parseRepresentation(xpp, baseUrl, mimeType, codecs, - width, height, frameRate, audioChannels, audioSamplingRate, language, segmentBase); + width, height, frameRate, audioChannels, audioSamplingRate, language, + accessibilityChannel, segmentBase); contentType = checkContentTypeConsistency(contentType, getContentType(representationInfo.format)); representationInfos.add(representationInfo); } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { audioChannels = parseAudioChannelConfiguration(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { + accessibilityChannel = parseAccessibilityValue(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { - segmentBase = parseSegmentBase(xpp, baseUrl, (SingleSegmentBase) segmentBase); + segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, baseUrl, (SegmentList) segmentBase); + segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { - segmentBase = parseSegmentTemplate(xpp, baseUrl, (SegmentTemplate) segmentBase); + segmentBase = parseSegmentTemplate(xpp, (SegmentTemplate) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp)) { parseAdaptationSetChild(xpp); } @@ -365,7 +372,8 @@ public class DashManifestParser extends DefaultHandler protected RepresentationInfo parseRepresentation(XmlPullParser xpp, String baseUrl, String adaptationSetMimeType, String adaptationSetCodecs, int adaptationSetWidth, int adaptationSetHeight, float adaptationSetFrameRate, int adaptationSetAudioChannels, - int adaptationSetAudioSamplingRate, String adaptationSetLanguage, SegmentBase segmentBase) + int adaptationSetAudioSamplingRate, String adaptationSetLanguage, + int adaptationSetAccessibilityChannel, SegmentBase segmentBase) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -390,11 +398,11 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { audioChannels = parseAudioChannelConfiguration(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { - segmentBase = parseSegmentBase(xpp, baseUrl, (SingleSegmentBase) segmentBase); + segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, baseUrl, (SegmentList) segmentBase); + segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { - segmentBase = parseSegmentTemplate(xpp, baseUrl, (SegmentTemplate) segmentBase); + segmentBase = parseSegmentTemplate(xpp, (SegmentTemplate) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { SchemeData contentProtection = parseContentProtection(xpp); if (contentProtection != null) { @@ -404,15 +412,16 @@ public class DashManifestParser extends DefaultHandler } while (!XmlPullParserUtil.isEndTag(xpp, "Representation")); Format format = buildFormat(id, mimeType, width, height, frameRate, audioChannels, - audioSamplingRate, bandwidth, adaptationSetLanguage, codecs); - segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(baseUrl); + audioSamplingRate, bandwidth, adaptationSetLanguage, adaptationSetAccessibilityChannel, + codecs); + segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(); - return new RepresentationInfo(format, segmentBase, drmSchemeDatas); + return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeDatas); } protected Format buildFormat(String id, String containerMimeType, int width, int height, float frameRate, int audioChannels, int audioSamplingRate, int bitrate, String language, - String codecs) { + int accessiblityChannel, String codecs) { String sampleMimeType = getSampleMimeType(containerMimeType, codecs); if (sampleMimeType != null) { if (MimeTypes.isVideo(sampleMimeType)) { @@ -423,7 +432,10 @@ public class DashManifestParser extends DefaultHandler bitrate, audioChannels, audioSamplingRate, null, 0, language); } else if (mimeTypeIsRawText(sampleMimeType)) { return Format.createTextContainerFormat(id, containerMimeType, sampleMimeType, codecs, - bitrate, 0, language); + bitrate, 0, language, accessiblityChannel); + } else if (containerMimeType.equals(MimeTypes.APPLICATION_RAWCC)) { + return Format.createTextContainerFormat(id, containerMimeType, sampleMimeType, codecs, + bitrate, 0, language, accessiblityChannel); } else { return Format.createContainerFormat(id, containerMimeType, codecs, sampleMimeType, bitrate); } @@ -441,13 +453,13 @@ public class DashManifestParser extends DefaultHandler format = format.copyWithDrmInitData(new DrmInitData(drmSchemeDatas)); } return Representation.newInstance(contentId, Representation.REVISION_ID_DEFAULT, format, - representationInfo.segmentBase); + representationInfo.baseUrl, representationInfo.segmentBase); } // SegmentBase, SegmentList and SegmentTemplate parsing. - protected SingleSegmentBase parseSegmentBase(XmlPullParser xpp, String baseUrl, - SingleSegmentBase parent) throws XmlPullParserException, IOException { + protected SingleSegmentBase parseSegmentBase(XmlPullParser xpp, SingleSegmentBase parent) + throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", @@ -466,21 +478,21 @@ public class DashManifestParser extends DefaultHandler do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { - initialization = parseInitialization(xpp, baseUrl); + initialization = parseInitialization(xpp); } } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentBase")); - return buildSingleSegmentBase(initialization, timescale, presentationTimeOffset, baseUrl, - indexStart, indexLength); + return buildSingleSegmentBase(initialization, timescale, presentationTimeOffset, indexStart, + indexLength); } protected SingleSegmentBase buildSingleSegmentBase(RangedUri initialization, long timescale, - long presentationTimeOffset, String baseUrl, long indexStart, long indexLength) { - return new SingleSegmentBase(initialization, timescale, presentationTimeOffset, baseUrl, - indexStart, indexLength); + long presentationTimeOffset, long indexStart, long indexLength) { + return new SingleSegmentBase(initialization, timescale, presentationTimeOffset, indexStart, + indexLength); } - protected SegmentList parseSegmentList(XmlPullParser xpp, String baseUrl, SegmentList parent) + protected SegmentList parseSegmentList(XmlPullParser xpp, SegmentList parent) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -496,14 +508,14 @@ public class DashManifestParser extends DefaultHandler do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { - initialization = parseInitialization(xpp, baseUrl); + initialization = parseInitialization(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { timeline = parseSegmentTimeline(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentURL")) { if (segments == null) { segments = new ArrayList<>(); } - segments.add(parseSegmentUrl(xpp, baseUrl)); + segments.add(parseSegmentUrl(xpp)); } } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentList")); @@ -524,8 +536,8 @@ public class DashManifestParser extends DefaultHandler startNumber, duration, timeline, segments); } - protected SegmentTemplate parseSegmentTemplate(XmlPullParser xpp, String baseUrl, - SegmentTemplate parent) throws XmlPullParserException, IOException { + protected SegmentTemplate parseSegmentTemplate(XmlPullParser xpp, SegmentTemplate parent) + throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); @@ -542,7 +554,7 @@ public class DashManifestParser extends DefaultHandler do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { - initialization = parseInitialization(xpp, baseUrl); + initialization = parseInitialization(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { timeline = parseSegmentTimeline(xpp); } @@ -554,15 +566,15 @@ public class DashManifestParser extends DefaultHandler } return buildSegmentTemplate(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, initializationTemplate, mediaTemplate, baseUrl); + startNumber, duration, timeline, initializationTemplate, mediaTemplate); } protected SegmentTemplate buildSegmentTemplate(RangedUri initialization, long timescale, long presentationTimeOffset, int startNumber, long duration, List timeline, UrlTemplate initializationTemplate, - UrlTemplate mediaTemplate, String baseUrl) { + UrlTemplate mediaTemplate) { return new SegmentTemplate(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, initializationTemplate, mediaTemplate, baseUrl); + startNumber, duration, timeline, initializationTemplate, mediaTemplate); } protected List parseSegmentTimeline(XmlPullParser xpp) @@ -597,15 +609,15 @@ public class DashManifestParser extends DefaultHandler return defaultValue; } - protected RangedUri parseInitialization(XmlPullParser xpp, String baseUrl) { - return parseRangedUrl(xpp, baseUrl, "sourceURL", "range"); + protected RangedUri parseInitialization(XmlPullParser xpp) { + return parseRangedUrl(xpp, "sourceURL", "range"); } - protected RangedUri parseSegmentUrl(XmlPullParser xpp, String baseUrl) { - return parseRangedUrl(xpp, baseUrl, "media", "mediaRange"); + protected RangedUri parseSegmentUrl(XmlPullParser xpp) { + return parseRangedUrl(xpp, "media", "mediaRange"); } - protected RangedUri parseRangedUrl(XmlPullParser xpp, String baseUrl, String urlAttribute, + protected RangedUri parseRangedUrl(XmlPullParser xpp, String urlAttribute, String rangeAttribute) { String urlText = xpp.getAttributeValue(null, urlAttribute); long rangeStart = 0; @@ -618,12 +630,11 @@ public class DashManifestParser extends DefaultHandler rangeLength = Long.parseLong(rangeTextArray[1]) - rangeStart + 1; } } - return buildRangedUri(baseUrl, urlText, rangeStart, rangeLength); + return buildRangedUri(urlText, rangeStart, rangeLength); } - protected RangedUri buildRangedUri(String baseUrl, String urlText, long rangeStart, - long rangeLength) { - return new RangedUri(baseUrl, urlText, rangeStart, rangeLength); + protected RangedUri buildRangedUri(String urlText, long rangeStart, long rangeLength) { + return new RangedUri(urlText, rangeStart, rangeLength); } // AudioChannelConfiguration parsing. @@ -727,6 +738,54 @@ public class DashManifestParser extends DefaultHandler } } + private static int parseAccessibilityValue(XmlPullParser xpp) + throws IOException, XmlPullParserException { + String schemeIdUri = parseString(xpp, "schemeIdUri", null); + String valueString = parseString(xpp, "value", null); + int accessibilityValue; + if (schemeIdUri == null || valueString == null) { + accessibilityValue = Format.NO_VALUE; + } else if ("urn:scte:dash:cc:cea-608:2015".equals(schemeIdUri)) { + accessibilityValue = parseCea608AccessibilityChannel(valueString); + } else if ("urn:scte:dash:cc:cea-708:2015".equals(schemeIdUri)) { + accessibilityValue = parseCea708AccessibilityChannel(valueString); + } else { + accessibilityValue = Format.NO_VALUE; + } + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, "Accessibility")); + return accessibilityValue; + } + + static int parseCea608AccessibilityChannel(String accessibilityValueString) { + if (accessibilityValueString == null) { + return Format.NO_VALUE; + } + Matcher accessibilityValueMatcher = + CEA_608_ACCESSIBILITY_PATTERN.matcher(accessibilityValueString); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse channel number from " + accessibilityValueString); + return Format.NO_VALUE; + } + } + + static int parseCea708AccessibilityChannel(String accessibilityValueString) { + if (accessibilityValueString == null) { + return Format.NO_VALUE; + } + Matcher accessibilityValueMatcher = + CEA_708_ACCESSIBILITY_PATTERN.matcher(accessibilityValueString); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse service block number from " + accessibilityValueString); + return Format.NO_VALUE; + } + } + protected static float parseFrameRate(XmlPullParser xpp, float defaultValue) { float frameRate = defaultValue; String frameRateAttribute = xpp.getAttributeValue(null, "frameRate"); @@ -755,7 +814,7 @@ public class DashManifestParser extends DefaultHandler } protected static long parseDateTime(XmlPullParser xpp, String name, long defaultValue) - throws ParseException { + throws ParserException { String value = xpp.getAttributeValue(null, name); if (value == null) { return defaultValue; @@ -788,12 +847,14 @@ public class DashManifestParser extends DefaultHandler private static final class RepresentationInfo { public final Format format; + public final String baseUrl; public final SegmentBase segmentBase; public final ArrayList drmSchemeDatas; - public RepresentationInfo(Format format, SegmentBase segmentBase, + public RepresentationInfo(Format format, String baseUrl, SegmentBase segmentBase, ArrayList drmSchemeDatas) { this.format = format; + this.baseUrl = baseUrl; this.segmentBase = segmentBase; this.drmSchemeDatas = drmSchemeDatas; } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java index 1d8b4fb300..269a63b7a9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Period.java @@ -41,12 +41,12 @@ public class Period { /** * @param id The period identifier. May be null. - * @param start The start time of the period in milliseconds. + * @param startMs The start time of the period in milliseconds. * @param adaptationSets The adaptation sets belonging to the period. */ - public Period(String id, long start, List adaptationSets) { + public Period(String id, long startMs, List adaptationSets) { this.id = id; - this.startMs = start; + this.startMs = startMs; this.adaptationSets = Collections.unmodifiableList(adaptationSets); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/RangedUri.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/RangedUri.java index 1668526b22..c2a64718df 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/RangedUri.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/RangedUri.java @@ -17,11 +17,10 @@ package com.google.android.exoplayer2.source.dash.manifest; import android.net.Uri; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.UriUtil; /** - * Defines a range of data located at a {@link Uri}. + * Defines a range of data located at a reference uri. */ public final class RangedUri { @@ -35,12 +34,6 @@ public final class RangedUri { */ public final long length; - // The URI is stored internally in two parts: reference URI and a base URI to use when - // resolving it. This helps optimize memory usage in the same way that DASH manifests allow many - // URLs to be expressed concisely in the form of a single BaseURL and many relative paths. Note - // that this optimization relies on the same object being passed as the base URI to many - // instances of this class. - private final String baseUri; private final String referenceUri; private int hashCode; @@ -48,57 +41,59 @@ public final class RangedUri { /** * Constructs an ranged uri. * - * @param baseUri A uri that can form the base of the uri defined by the instance. - * @param referenceUri A reference uri that should be resolved with respect to {@code baseUri}. + * @param referenceUri The reference uri. * @param start The (zero based) index of the first byte of the range. * @param length The length of the range, or {@link C#LENGTH_UNSET} to indicate that the range is * unbounded. */ - public RangedUri(String baseUri, String referenceUri, long start, long length) { - Assertions.checkArgument(baseUri != null || referenceUri != null); - this.baseUri = baseUri; - this.referenceUri = referenceUri; + public RangedUri(String referenceUri, long start, long length) { + this.referenceUri = referenceUri == null ? "" : referenceUri; this.start = start; this.length = length; } /** - * Returns the {@link Uri} represented by the instance. + * Returns the resolved {@link Uri} represented by the instance. * + * @param baseUri The base Uri. * @return The {@link Uri} represented by the instance. */ - public Uri getUri() { + public Uri resolveUri(String baseUri) { return UriUtil.resolveToUri(baseUri, referenceUri); } /** - * Returns the uri represented by the instance as a string. + * Returns the resolved uri represented by the instance as a string. * + * @param baseUri The base Uri. * @return The uri represented by the instance. */ - public String getUriString() { + public String resolveUriString(String baseUri) { return UriUtil.resolve(baseUri, referenceUri); } /** - * Attempts to merge this {@link RangedUri} with another. + * Attempts to merge this {@link RangedUri} with another and an optional common base uri. *

- * A merge is successful if both instances define the same {@link Uri}, and if one starts the byte - * after the other ends, forming a contiguous region with no overlap. + * A merge is successful if both instances define the same {@link Uri} after resolution with the + * base uri, and if one starts the byte after the other ends, forming a contiguous region with + * no overlap. *

* If {@code other} is null then the merge is considered unsuccessful, and null is returned. * * @param other The {@link RangedUri} to merge. + * @param baseUri The optional base Uri. * @return The merged {@link RangedUri} if the merge was successful. Null otherwise. */ - public RangedUri attemptMerge(RangedUri other) { - if (other == null || !getUriString().equals(other.getUriString())) { + public RangedUri attemptMerge(RangedUri other, String baseUri) { + final String resolvedUri = resolveUriString(baseUri); + if (other == null || !resolvedUri.equals(other.resolveUriString(baseUri))) { return null; } else if (length != C.LENGTH_UNSET && start + length == other.start) { - return new RangedUri(baseUri, referenceUri, start, + return new RangedUri(resolvedUri, start, other.length == C.LENGTH_UNSET ? C.LENGTH_UNSET : length + other.length); } else if (other.length != C.LENGTH_UNSET && other.start + other.length == start) { - return new RangedUri(baseUri, referenceUri, other.start, + return new RangedUri(resolvedUri, other.start, length == C.LENGTH_UNSET ? C.LENGTH_UNSET : other.length + length); } else { return null; @@ -111,7 +106,7 @@ public final class RangedUri { int result = 17; result = 31 * result + (int) start; result = 31 * result + (int) length; - result = 31 * result + getUriString().hashCode(); + result = 31 * result + referenceUri.hashCode(); hashCode = result; } return hashCode; @@ -128,7 +123,7 @@ public final class RangedUri { RangedUri other = (RangedUri) obj; return this.start == other.start && this.length == other.length - && getUriString().equals(other.getUriString()); + && referenceUri.equals(other.referenceUri); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 9c6d2e1582..f52727c1a8 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -52,12 +52,15 @@ public abstract class Representation { * The format of the representation. */ public final Format format; + /** + * The base URL of the representation. + */ + public final String baseUrl; /** * The offset of the presentation timestamps in the media stream relative to media time. */ public final long presentationTimeOffsetUs; - private final String cacheKey; private final RangedUri initializationUri; /** @@ -66,12 +69,13 @@ public abstract class Representation { * @param contentId Identifies the piece of content to which this representation belongs. * @param revisionId Identifies the revision of the content. * @param format The format of the representation. + * @param baseUrl The base URL. * @param segmentBase A segment base element for the representation. * @return The constructed instance. */ public static Representation newInstance(String contentId, long revisionId, Format format, - SegmentBase segmentBase) { - return newInstance(contentId, revisionId, format, segmentBase, null); + String baseUrl, SegmentBase segmentBase) { + return newInstance(contentId, revisionId, format, baseUrl, segmentBase, null); } /** @@ -80,31 +84,32 @@ public abstract class Representation { * @param contentId Identifies the piece of content to which this representation belongs. * @param revisionId Identifies the revision of the content. * @param format The format of the representation. + * @param baseUrl The base URL of the representation. * @param segmentBase A segment base element for the representation. - * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. + * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. This + * parameter is ignored if {@code segmentBase} consists of multiple segments. * @return The constructed instance. */ public static Representation newInstance(String contentId, long revisionId, Format format, - SegmentBase segmentBase, String customCacheKey) { + String baseUrl, SegmentBase segmentBase, String customCacheKey) { if (segmentBase instanceof SingleSegmentBase) { - return new SingleSegmentRepresentation(contentId, revisionId, format, + return new SingleSegmentRepresentation(contentId, revisionId, format, baseUrl, (SingleSegmentBase) segmentBase, customCacheKey, C.LENGTH_UNSET); } else if (segmentBase instanceof MultiSegmentBase) { - return new MultiSegmentRepresentation(contentId, revisionId, format, - (MultiSegmentBase) segmentBase, customCacheKey); + return new MultiSegmentRepresentation(contentId, revisionId, format, baseUrl, + (MultiSegmentBase) segmentBase); } else { throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); } } - private Representation(String contentId, long revisionId, Format format, - SegmentBase segmentBase, String customCacheKey) { + private Representation(String contentId, long revisionId, Format format, String baseUrl, + SegmentBase segmentBase) { this.contentId = contentId; this.revisionId = revisionId; this.format = format; - this.cacheKey = customCacheKey != null ? customCacheKey - : contentId + "." + format.id + "." + revisionId; + this.baseUrl = baseUrl; initializationUri = segmentBase.getInitialization(this); presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs(); } @@ -129,12 +134,10 @@ public abstract class Representation { public abstract DashSegmentIndex getIndex(); /** - * Returns a cache key for the representation, in the format - * {@code contentId + "." + format.id + "." + revisionId}. + * Returns a cache key for the representation if a custom cache key or content id has been + * provided and there is only single segment. */ - public String getCacheKey() { - return cacheKey; - } + public abstract String getCacheKey(); /** * A DASH representation consisting of a single segment. @@ -151,6 +154,7 @@ public abstract class Representation { */ public final long contentLength; + private final String cacheKey; private final RangedUri indexUri; private final SingleSegmentIndex segmentIndex; @@ -169,32 +173,35 @@ public abstract class Representation { public static SingleSegmentRepresentation newInstance(String contentId, long revisionId, Format format, String uri, long initializationStart, long initializationEnd, long indexStart, long indexEnd, String customCacheKey, long contentLength) { - RangedUri rangedUri = new RangedUri(uri, null, initializationStart, + RangedUri rangedUri = new RangedUri(null, initializationStart, initializationEnd - initializationStart + 1); - SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, uri, indexStart, + SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, indexStart, indexEnd - indexStart + 1); return new SingleSegmentRepresentation(contentId, revisionId, - format, segmentBase, customCacheKey, contentLength); + format, uri, segmentBase, customCacheKey, contentLength); } /** * @param contentId Identifies the piece of content to which this representation belongs. * @param revisionId Identifies the revision of the content. * @param format The format of the representation. + * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. * @param contentLength The content length, or {@link C#LENGTH_UNSET} if unknown. */ public SingleSegmentRepresentation(String contentId, long revisionId, Format format, - SingleSegmentBase segmentBase, String customCacheKey, long contentLength) { - super(contentId, revisionId, format, segmentBase, customCacheKey); - this.uri = Uri.parse(segmentBase.uri); + String baseUrl, SingleSegmentBase segmentBase, String customCacheKey, long contentLength) { + super(contentId, revisionId, format, baseUrl, segmentBase); + this.uri = Uri.parse(baseUrl); this.indexUri = segmentBase.getIndex(); + this.cacheKey = customCacheKey != null ? customCacheKey + : contentId != null ? contentId + "." + format.id + "." + revisionId : null; this.contentLength = contentLength; // If we have an index uri then the index is defined externally, and we shouldn't return one // directly. If we don't, then we can't do better than an index defining a single segment. segmentIndex = indexUri != null ? null - : new SingleSegmentIndex(new RangedUri(segmentBase.uri, null, 0, contentLength)); + : new SingleSegmentIndex(new RangedUri(null, 0, contentLength)); } @Override @@ -207,6 +214,11 @@ public abstract class Representation { return segmentIndex; } + @Override + public String getCacheKey() { + return cacheKey; + } + } /** @@ -221,12 +233,12 @@ public abstract class Representation { * @param contentId Identifies the piece of content to which this representation belongs. * @param revisionId Identifies the revision of the content. * @param format The format of the representation. + * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. - * @param customCacheKey A custom value to be returned from {@link #getCacheKey()}, or null. */ public MultiSegmentRepresentation(String contentId, long revisionId, Format format, - MultiSegmentBase segmentBase, String customCacheKey) { - super(contentId, revisionId, format, segmentBase, customCacheKey); + String baseUrl, MultiSegmentBase segmentBase) { + super(contentId, revisionId, format, baseUrl, segmentBase); this.segmentBase = segmentBase; } @@ -240,6 +252,11 @@ public abstract class Representation { return this; } + @Override + public String getCacheKey() { + return null; + } + // DashSegmentIndex implementation. @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java index dec626c326..ef319d508d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java @@ -65,11 +65,6 @@ public abstract class SegmentBase { */ public static class SingleSegmentBase extends SegmentBase { - /** - * The uri of the segment. - */ - public final String uri; - /* package */ final long indexStart; /* package */ final long indexLength; @@ -79,27 +74,22 @@ public abstract class SegmentBase { * @param timescale The timescale in units per second. * @param presentationTimeOffset The presentation time offset. The value in seconds is the * division of this value and {@code timescale}. - * @param uri The uri of the segment. * @param indexStart The byte offset of the index data in the segment. * @param indexLength The length of the index data in bytes. */ public SingleSegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset, - String uri, long indexStart, long indexLength) { + long indexStart, long indexLength) { super(initialization, timescale, presentationTimeOffset); - this.uri = uri; this.indexStart = indexStart; this.indexLength = indexLength; } - /** - * @param uri The uri of the segment. - */ - public SingleSegmentBase(String uri) { - this(null, 1, 0, uri, 0, 0); + public SingleSegmentBase() { + this(null, 1, 0, 0, 0); } public RangedUri getIndex() { - return indexLength <= 0 ? null : new RangedUri(uri, null, indexStart, indexLength); + return indexLength <= 0 ? null : new RangedUri(null, indexStart, indexLength); } } @@ -279,8 +269,6 @@ public abstract class SegmentBase { /* package */ final UrlTemplate initializationTemplate; /* package */ final UrlTemplate mediaTemplate; - private final String baseUrl; - /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data * exists. The value of this parameter is ignored if {@code initializationTemplate} is @@ -299,16 +287,14 @@ public abstract class SegmentBase { * such data exists. If non-null then the {@code initialization} parameter is ignored. If * null then {@code initialization} will be used. * @param mediaTemplate A template defining the location of each media segment. - * @param baseUrl A url to use as the base for relative urls generated by the templates. */ public SegmentTemplate(RangedUri initialization, long timescale, long presentationTimeOffset, int startNumber, long duration, List segmentTimeline, - UrlTemplate initializationTemplate, UrlTemplate mediaTemplate, String baseUrl) { + UrlTemplate initializationTemplate, UrlTemplate mediaTemplate) { super(initialization, timescale, presentationTimeOffset, startNumber, duration, segmentTimeline); this.initializationTemplate = initializationTemplate; this.mediaTemplate = mediaTemplate; - this.baseUrl = baseUrl; } @Override @@ -316,7 +302,7 @@ public abstract class SegmentBase { if (initializationTemplate != null) { String urlString = initializationTemplate.buildUri(representation.format.id, 0, representation.format.bitrate, 0); - return new RangedUri(baseUrl, urlString, 0, C.LENGTH_UNSET); + return new RangedUri(urlString, 0, C.LENGTH_UNSET); } else { return super.getInitialization(representation); } @@ -332,7 +318,7 @@ public abstract class SegmentBase { } String uriString = mediaTemplate.buildUri(representation.format.id, sequenceNumber, representation.format.bitrate, time); - return new RangedUri(baseUrl, uriString, 0, C.LENGTH_UNSET); + return new RangedUri(uriString, 0, C.LENGTH_UNSET); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 53d9e70d76..b953fcf79c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -17,34 +17,24 @@ package com.google.android.exoplayer2.source.hls; import android.net.Uri; import android.os.SystemClock; -import android.text.TextUtils; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.TimestampAdjuster; -import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; -import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; -import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; -import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; -import com.google.android.exoplayer2.extractor.ts.DefaultStreamReaderFactory; -import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.ChunkedTrackBlacklistUtil; import com.google.android.exoplayer2.source.chunk.DataChunk; -import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.trackselection.BaseTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigInteger; import java.util.Arrays; @@ -65,7 +55,7 @@ import java.util.Locale; } /** - * The chunk. + * The chunk to be loaded next. */ public Chunk chunk; @@ -75,9 +65,9 @@ import java.util.Locale; public boolean endOfStream; /** - * Milliseconds to wait before retrying. + * Indicates that the chunk source is waiting for the referred playlist to be refreshed. */ - public long retryInMs; + public HlsUrl playlist; /** * Clears the holder. @@ -85,43 +75,21 @@ import java.util.Locale; public void clear() { chunk = null; endOfStream = false; - retryInMs = C.TIME_UNSET; + playlist = null; } } - /** - * The default time for which a media playlist should be blacklisted. - */ - public static final long DEFAULT_PLAYLIST_BLACKLIST_MS = 60000; - /** - * Subtracted value to lookup position when switching between variants in live streams to avoid - * gaps in playback in case playlist drift apart. - */ - private static final double LIVE_VARIANT_SWITCH_SAFETY_EXTRA_SECS = 2.0; - private static final String AAC_FILE_EXTENSION = ".aac"; - private static final String AC3_FILE_EXTENSION = ".ac3"; - private static final String EC3_FILE_EXTENSION = ".ec3"; - private static final String MP3_FILE_EXTENSION = ".mp3"; - private static final String MP4_FILE_EXTENSION = ".mp4"; - private static final String VTT_FILE_EXTENSION = ".vtt"; - private static final String WEBVTT_FILE_EXTENSION = ".webvtt"; - - private final String baseUri; private final DataSource dataSource; - private final HlsPlaylistParser playlistParser; private final TimestampAdjusterProvider timestampAdjusterProvider; - private final HlsMasterPlaylist.HlsUrl[] variants; - private final HlsMediaPlaylist[] variantPlaylists; + private final HlsUrl[] variants; + private final HlsPlaylistTracker playlistTracker; private final TrackGroup trackGroup; - private final long[] variantLastPlaylistLoadTimesMs; + private boolean isTimestampMaster; private byte[] scratchSpace; - private boolean live; - private long durationUs; private IOException fatalError; - private HlsInitializationChunk lastLoadedInitializationChunk; private Uri encryptionKeyUri; private byte[] encryptionKey; private String encryptionIvString; @@ -133,22 +101,19 @@ import java.util.Locale; private TrackSelection trackSelection; /** - * @param baseUri The playlist's base uri. + * @param playlistTracker The {@link HlsPlaylistTracker} from which to obtain media playlists. * @param variants The available variants. * @param dataSource A {@link DataSource} suitable for loading the media data. * @param timestampAdjusterProvider A provider of {@link TimestampAdjuster} instances. If * multiple {@link HlsChunkSource}s are used for a single playback, they should all share the * same provider. */ - public HlsChunkSource(String baseUri, HlsMasterPlaylist.HlsUrl[] variants, DataSource dataSource, - TimestampAdjusterProvider timestampAdjusterProvider) { - this.baseUri = baseUri; + public HlsChunkSource(HlsPlaylistTracker playlistTracker, HlsUrl[] variants, + DataSource dataSource, TimestampAdjusterProvider timestampAdjusterProvider) { + this.playlistTracker = playlistTracker; this.variants = variants; this.dataSource = dataSource; this.timestampAdjusterProvider = timestampAdjusterProvider; - playlistParser = new HlsPlaylistParser(); - variantPlaylists = new HlsMediaPlaylist[variants.length]; - variantLastPlaylistLoadTimesMs = new long[variants.length]; Format[] variantFormats = new Format[variants.length]; int[] initialTrackSelection = new int[variants.length]; @@ -172,20 +137,6 @@ import java.util.Locale; } } - /** - * Returns whether this is a live playback. - */ - public boolean isLive() { - return live; - } - - /** - * Returns the duration of the source, or {@link C#TIME_UNSET} if the duration is unknown. - */ - public long getDurationUs() { - return durationUs; - } - /** * Returns the track group exposed by the source. */ @@ -209,13 +160,23 @@ import java.util.Locale; fatalError = null; } + /** + * Sets whether this chunk source is responsible for initializing timestamp adjusters. + * + * @param isTimestampMaster True if this chunk source is responsible for initializing timestamp + * adjusters. + */ + public void setIsTimestampMaster(boolean isTimestampMaster) { + this.isTimestampMaster = isTimestampMaster; + } + /** * Returns the next chunk to load. *

* If a chunk is available then {@link HlsChunkHolder#chunk} is set. If the end of the stream has * been reached then {@link HlsChunkHolder#endOfStream} is set. If a chunk is not available but - * the end of the stream has not been reached, {@link HlsChunkHolder#retryInMs} is set to contain - * the amount of milliseconds to wait before retrying. + * the end of the stream has not been reached, {@link HlsChunkHolder#playlist} is set to + * contain the {@link HlsUrl} that refers to the playlist that needs refreshing. * * @param previous The most recently loaded media chunk. * @param playbackPositionUs The current playback position. If {@code previous} is null then this @@ -226,80 +187,61 @@ import java.util.Locale; public void getNextChunk(HlsMediaChunk previous, long playbackPositionUs, HlsChunkHolder out) { int oldVariantIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); - // Use start time of the previous chunk rather than its end time because switching format will // require downloading overlapping segments. long bufferedDurationUs = previous == null ? 0 - : Math.max(0, previous.getAdjustedStartTimeUs() - playbackPositionUs); + : Math.max(0, previous.startTimeUs - playbackPositionUs); + + // Select the variant. trackSelection.updateSelectedTrack(bufferedDurationUs); int newVariantIndex = trackSelection.getSelectedIndexInTrackGroup(); boolean switchingVariant = oldVariantIndex != newVariantIndex; - HlsMediaPlaylist mediaPlaylist = variantPlaylists[newVariantIndex]; + HlsMediaPlaylist mediaPlaylist = playlistTracker.getPlaylistSnapshot(variants[newVariantIndex]); if (mediaPlaylist == null) { - // We don't have the media playlist for the next variant. Request it now. - out.chunk = newMediaPlaylistChunk(newVariantIndex, trackSelection.getSelectionReason(), - trackSelection.getSelectionData()); + out.playlist = variants[newVariantIndex]; + // Retry when playlist is refreshed. return; } + // Select the chunk. int chunkMediaSequence; - if (live) { - if (previous == null) { - // When playing a live stream, the starting chunk will be the third counting from the live - // edge. - chunkMediaSequence = Math.max(0, mediaPlaylist.segments.size() - 3) - + mediaPlaylist.mediaSequence; - // TODO: Bring this back for live window seeking. - // chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, playbackPositionUs, - // true, true) + mediaPlaylist.mediaSequence; + if (previous == null || switchingVariant) { + long targetPositionUs = previous == null ? playbackPositionUs : previous.startTimeUs; + if (!mediaPlaylist.hasEndTag && targetPositionUs > mediaPlaylist.getEndTimeUs()) { + // If the playlist is too old to contain the chunk, we need to refresh it. + chunkMediaSequence = mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(); } else { - chunkMediaSequence = getLiveNextChunkSequenceNumber(previous.chunkIndex, oldVariantIndex, - newVariantIndex); - if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, + targetPositionUs - mediaPlaylist.startTimeUs, true, + !playlistTracker.isLive() || previous == null) + mediaPlaylist.mediaSequence; + if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null) { // We try getting the next chunk without adapting in case that's the reason for falling // behind the live window. newVariantIndex = oldVariantIndex; - mediaPlaylist = variantPlaylists[newVariantIndex]; - chunkMediaSequence = getLiveNextChunkSequenceNumber(previous.chunkIndex, oldVariantIndex, - newVariantIndex); - if (chunkMediaSequence < mediaPlaylist.mediaSequence) { - fatalError = new BehindLiveWindowException(); - return; - } + mediaPlaylist = playlistTracker.getPlaylistSnapshot(variants[newVariantIndex]); + chunkMediaSequence = previous.getNextChunkIndex(); } } } else { - // Not live. - if (previous == null) { - chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, playbackPositionUs, - true, true) + mediaPlaylist.mediaSequence; - } else if (switchingVariant) { - chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, - previous.startTimeUs, true, true) + mediaPlaylist.mediaSequence; - } else { - chunkMediaSequence = previous.getNextChunkIndex(); - } + chunkMediaSequence = previous.getNextChunkIndex(); + } + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + fatalError = new BehindLiveWindowException(); + return; } int chunkIndex = chunkMediaSequence - mediaPlaylist.mediaSequence; if (chunkIndex >= mediaPlaylist.segments.size()) { - if (!mediaPlaylist.live) { + if (mediaPlaylist.hasEndTag) { out.endOfStream = true; } else /* Live */ { - long msToRerequestLiveMediaPlaylist = msToRerequestLiveMediaPlaylist(newVariantIndex); - if (msToRerequestLiveMediaPlaylist <= 0) { - out.chunk = newMediaPlaylistChunk(newVariantIndex, - trackSelection.getSelectionReason(), trackSelection.getSelectionData()); - } else { - // 10 milliseconds are added to the wait to make sure the playlist is refreshed when - // getNextChunk() is called. - out.retryInMs = msToRerequestLiveMediaPlaylist + 10; - } + out.playlist = variants[newVariantIndex]; } return; } + // Handle encryption. HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(chunkIndex); // Check if encryption is specified. @@ -318,154 +260,30 @@ import java.util.Locale; clearEncryptionData(); } - // Compute start and end times, and the sequence number of the next chunk. - long startTimeUs; - if (live) { - if (previous == null) { - startTimeUs = 0; - } else if (switchingVariant) { - startTimeUs = previous.getAdjustedStartTimeUs(); - } else { - startTimeUs = previous.getAdjustedEndTimeUs(); - } - } else /* Not live */ { - startTimeUs = segment.startTimeUs; - } - long endTimeUs = startTimeUs + (long) (segment.durationSecs * C.MICROS_PER_SECOND); - Format format = variants[newVariantIndex].format; - - Uri chunkUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, segment.url); - - // Configure the extractor that will read the chunk. - Extractor extractor; - boolean useInitializedExtractor = lastLoadedInitializationChunk != null - && lastLoadedInitializationChunk.format == format; - boolean needNewExtractor = previous == null - || previous.discontinuitySequenceNumber != segment.discontinuitySequenceNumber - || format != previous.trackFormat; - boolean extractorNeedsInit = true; - boolean isTimestampMaster = false; - TimestampAdjuster timestampAdjuster = null; - String lastPathSegment = chunkUri.getLastPathSegment(); - if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { - // TODO: Inject a timestamp adjuster and use it along with ID3 PRIV tag values with owner - // identifier com.apple.streaming.transportStreamTimestamp. This may also apply to the MP3 - // case below. - extractor = new AdtsExtractor(startTimeUs); - } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) - || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { - extractor = new Ac3Extractor(startTimeUs); - } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { - extractor = new Mp3Extractor(startTimeUs); - } else if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) - || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { - timestampAdjuster = timestampAdjusterProvider.getAdjuster(segment.discontinuitySequenceNumber, - startTimeUs); - extractor = new WebvttExtractor(format.language, timestampAdjuster); - } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION)) { - isTimestampMaster = true; - if (needNewExtractor) { - if (useInitializedExtractor) { - extractor = lastLoadedInitializationChunk.extractor; - } else { - timestampAdjuster = timestampAdjusterProvider.getAdjuster( - segment.discontinuitySequenceNumber, startTimeUs); - extractor = new FragmentedMp4Extractor(0, timestampAdjuster); - } - } else { - extractor = previous.extractor; - } - } else if (needNewExtractor) { - // MPEG-2 TS segments, but we need a new extractor. - isTimestampMaster = true; - if (useInitializedExtractor) { - extractor = lastLoadedInitializationChunk.extractor; - } else { - timestampAdjuster = timestampAdjusterProvider.getAdjuster( - segment.discontinuitySequenceNumber, startTimeUs); - // This flag ensures the change of pid between streams does not affect the sample queues. - @DefaultStreamReaderFactory.Flags - int esReaderFactoryFlags = 0; - String codecs = variants[newVariantIndex].format.codecs; - if (!TextUtils.isEmpty(codecs)) { - // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really - // exist. If we know from the codec attribute that they don't exist, then we can - // explicitly ignore them even if they're declared. - if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { - esReaderFactoryFlags |= DefaultStreamReaderFactory.FLAG_IGNORE_AAC_STREAM; - } - if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { - esReaderFactoryFlags |= DefaultStreamReaderFactory.FLAG_IGNORE_H264_STREAM; - } - } - extractor = new TsExtractor(timestampAdjuster, - new DefaultStreamReaderFactory(esReaderFactoryFlags), true); - } - } else { - // MPEG-2 TS segments, and we need to continue using the same extractor. - extractor = previous.extractor; - extractorNeedsInit = false; + DataSpec initDataSpec = null; + Segment initSegment = mediaPlaylist.initializationSegment; + if (initSegment != null) { + Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url); + initDataSpec = new DataSpec(initSegmentUri, initSegment.byterangeOffset, + initSegment.byterangeLength, null); } - if (needNewExtractor && mediaPlaylist.initializationSegment != null - && !useInitializedExtractor) { - out.chunk = buildInitializationChunk(mediaPlaylist, extractor, format); - return; - } + // Compute start time of the next chunk. + long startTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs; + TimestampAdjuster timestampAdjuster = timestampAdjusterProvider.getAdjuster( + segment.discontinuitySequenceNumber, startTimeUs); - lastLoadedInitializationChunk = null; // Configure the data source and spec for the chunk. + Uri chunkUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, segment.url); DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength, null); - out.chunk = new HlsMediaChunk(dataSource, dataSpec, format, + out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex], trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - startTimeUs, endTimeUs, chunkMediaSequence, segment.discontinuitySequenceNumber, - isTimestampMaster, timestampAdjuster, extractor, extractorNeedsInit, switchingVariant, + startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence, + segment.discontinuitySequenceNumber, isTimestampMaster, timestampAdjuster, previous, encryptionKey, encryptionIv); } - /** - * Returns the media sequence number of a chunk in a new variant for a live stream variant switch. - * - * @param previousChunkIndex The index of the last chunk in the old variant. - * @param oldVariantIndex The index of the old variant. - * @param newVariantIndex The index of the new variant. - * @return Media sequence number of the chunk to switch to in a live stream in the variant that - * corresponds to the given {@code newVariantIndex}. - */ - private int getLiveNextChunkSequenceNumber(int previousChunkIndex, int oldVariantIndex, - int newVariantIndex) { - if (oldVariantIndex == newVariantIndex) { - return previousChunkIndex + 1; - } - HlsMediaPlaylist oldMediaPlaylist = variantPlaylists[oldVariantIndex]; - HlsMediaPlaylist newMediaPlaylist = variantPlaylists[newVariantIndex]; - double offsetToLiveInstantSecs = 0; - for (int i = previousChunkIndex - oldMediaPlaylist.mediaSequence; - i < oldMediaPlaylist.segments.size(); i++) { - offsetToLiveInstantSecs += oldMediaPlaylist.segments.get(i).durationSecs; - } - long currentTimeMs = SystemClock.elapsedRealtime(); - offsetToLiveInstantSecs += - (double) (currentTimeMs - variantLastPlaylistLoadTimesMs[oldVariantIndex]) / 1000; - offsetToLiveInstantSecs += LIVE_VARIANT_SWITCH_SAFETY_EXTRA_SECS; - offsetToLiveInstantSecs -= - (double) (currentTimeMs - variantLastPlaylistLoadTimesMs[newVariantIndex]) / 1000; - if (offsetToLiveInstantSecs < 0) { - // The instant we are looking for is not contained in the playlist, we need it to be - // refreshed. - return newMediaPlaylist.mediaSequence + newMediaPlaylist.segments.size() + 1; - } - for (int i = newMediaPlaylist.segments.size() - 1; i >= 0; i--) { - offsetToLiveInstantSecs -= newMediaPlaylist.segments.get(i).durationSecs; - if (offsetToLiveInstantSecs < 0) { - return newMediaPlaylist.mediaSequence + i; - } - } - // We have fallen behind the live window. - return newMediaPlaylist.mediaSequence - 1; - } - /** * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this * source. @@ -473,13 +291,7 @@ import java.util.Locale; * @param chunk The chunk whose load has been completed. */ public void onChunkLoadCompleted(Chunk chunk) { - if (chunk instanceof HlsInitializationChunk) { - lastLoadedInitializationChunk = (HlsInitializationChunk) chunk; - } else if (chunk instanceof MediaPlaylistChunk) { - MediaPlaylistChunk mediaPlaylistChunk = (MediaPlaylistChunk) chunk; - scratchSpace = mediaPlaylistChunk.getDataHolder(); - setMediaPlaylist(mediaPlaylistChunk.variantIndex, mediaPlaylistChunk.getResult()); - } else if (chunk instanceof EncryptionKeyChunk) { + if (chunk instanceof EncryptionKeyChunk) { EncryptionKeyChunk encryptionKeyChunk = (EncryptionKeyChunk) chunk; scratchSpace = encryptionKeyChunk.getDataHolder(); setEncryptionData(encryptionKeyChunk.dataSpec.uri, encryptionKeyChunk.iv, @@ -493,46 +305,32 @@ import java.util.Locale; * * @param chunk The chunk whose load encountered the error. * @param cancelable Whether the load can be canceled. - * @param e The error. + * @param error The error. * @return Whether the load should be canceled. */ - public boolean onChunkLoadError(Chunk chunk, boolean cancelable, IOException e) { + public boolean onChunkLoadError(Chunk chunk, boolean cancelable, IOException error) { return cancelable && ChunkedTrackBlacklistUtil.maybeBlacklistTrack(trackSelection, - trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), e); + trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), error); + } + + /** + * Called when a playlist is blacklisted. + * + * @param url The url that references the blacklisted playlist. + * @param blacklistMs The amount of milliseconds for which the playlist was blacklisted. + */ + public void onPlaylistBlacklisted(HlsUrl url, long blacklistMs) { + int trackGroupIndex = trackGroup.indexOf(url.format); + if (trackGroupIndex != C.INDEX_UNSET) { + int trackSelectionIndex = trackSelection.indexOf(trackGroupIndex); + if (trackSelectionIndex != C.INDEX_UNSET) { + trackSelection.blacklist(trackSelectionIndex, blacklistMs); + } + } } // Private methods. - private HlsInitializationChunk buildInitializationChunk(HlsMediaPlaylist mediaPlaylist, - Extractor extractor, Format format) { - Segment initSegment = mediaPlaylist.initializationSegment; - // The initialization segment is required before the actual media chunk. - Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url); - DataSpec initDataSpec = new DataSpec(initSegmentUri, initSegment.byterangeOffset, - initSegment.byterangeLength, null); - return new HlsInitializationChunk(dataSource, initDataSpec, - trackSelection.getSelectionReason(), trackSelection.getSelectionData(), extractor, - format); - } - - private long msToRerequestLiveMediaPlaylist(int variantIndex) { - HlsMediaPlaylist mediaPlaylist = variantPlaylists[variantIndex]; - long timeSinceLastMediaPlaylistLoadMs = - SystemClock.elapsedRealtime() - variantLastPlaylistLoadTimesMs[variantIndex]; - // Don't re-request media playlist more often than one-half of the target duration. - return (mediaPlaylist.targetDurationSecs * 1000) / 2 - timeSinceLastMediaPlaylistLoadMs; - } - - private MediaPlaylistChunk newMediaPlaylistChunk(int variantIndex, int trackSelectionReason, - Object trackSelectionData) { - Uri mediaPlaylistUri = UriUtil.resolveToUri(baseUri, variants[variantIndex].url); - DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNSET, null, - DataSpec.FLAG_ALLOW_GZIP); - return new MediaPlaylistChunk(dataSource, dataSpec, variants[variantIndex].format, - trackSelectionReason, trackSelectionData, scratchSpace, playlistParser, variantIndex, - mediaPlaylistUri); - } - private EncryptionKeyChunk newEncryptionKeyChunk(Uri keyUri, String iv, int variantIndex, int trackSelectionReason, Object trackSelectionData) { DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNSET, null, DataSpec.FLAG_ALLOW_GZIP); @@ -567,13 +365,6 @@ import java.util.Locale; encryptionIv = null; } - private void setMediaPlaylist(int variantIndex, HlsMediaPlaylist mediaPlaylist) { - variantLastPlaylistLoadTimesMs[variantIndex] = SystemClock.elapsedRealtime(); - variantPlaylists[variantIndex] = mediaPlaylist; - live |= mediaPlaylist.live; - durationUs = live ? C.TIME_UNSET : mediaPlaylist.durationUs; - } - // Private classes. /** @@ -622,38 +413,6 @@ import java.util.Locale; } - private static final class MediaPlaylistChunk extends DataChunk { - - public final int variantIndex; - - private final HlsPlaylistParser playlistParser; - private final Uri playlistUri; - - private HlsMediaPlaylist result; - - public MediaPlaylistChunk(DataSource dataSource, DataSpec dataSpec, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, byte[] scratchSpace, - HlsPlaylistParser playlistParser, int variantIndex, - Uri playlistUri) { - super(dataSource, dataSpec, C.DATA_TYPE_MANIFEST, trackFormat, trackSelectionReason, - trackSelectionData, scratchSpace); - this.variantIndex = variantIndex; - this.playlistParser = playlistParser; - this.playlistUri = playlistUri; - } - - @Override - protected void consume(byte[] data, int limit) throws IOException { - result = (HlsMediaPlaylist) playlistParser.parse(playlistUri, - new ByteArrayInputStream(data, 0, limit)); - } - - public HlsMediaPlaylist getResult() { - return result; - } - - } - private static final class EncryptionKeyChunk extends DataChunk { public final String iv; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsInitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsInitializationChunk.java deleted file mode 100644 index c571b2f9df..0000000000 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsInitializationChunk.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.source.hls; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.extractor.DefaultExtractorInput; -import com.google.android.exoplayer2.extractor.Extractor; -import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.source.chunk.Chunk; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Util; - -import java.io.IOException; - -/** - * An HLS initialization chunk. Provides the extractor with information required for extracting the - * samples. - */ -/* package */ final class HlsInitializationChunk extends Chunk { - - public final Format format; - - public final Extractor extractor; - - private int bytesLoaded; - private volatile boolean loadCanceled; - - public HlsInitializationChunk(DataSource dataSource, DataSpec dataSpec, int trackSelectionReason, - Object trackSelectionData, Extractor extractor, Format format) { - super(dataSource, dataSpec, C.TRACK_TYPE_DEFAULT, null, trackSelectionReason, - trackSelectionData, C.TIME_UNSET, C.TIME_UNSET); - this.extractor = extractor; - this.format = format; - } - - /** - * Sets the {@link HlsSampleStreamWrapper} that will receive the sample format information from - * the initialization chunk. - * - * @param output The output that will receive the format information. - */ - public void init(HlsSampleStreamWrapper output) { - extractor.init(output); - } - - @Override - public long bytesLoaded() { - return bytesLoaded; - } - - @Override - public void cancelLoad() { - loadCanceled = true; - } - - @Override - public boolean isLoadCanceled() { - return loadCanceled; - } - - @Override - public void load() throws IOException, InterruptedException { - DataSpec loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded); - try { - ExtractorInput input = new DefaultExtractorInput(dataSource, - loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); - try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, null); - } - } finally { - bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); - } - } finally { - dataSource.close(); - } - } - -} diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 5c316a5653..f9dba14e0e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -15,14 +15,28 @@ */ package com.google.android.exoplayer2.source.hls; -import com.google.android.exoplayer2.Format; +import android.text.TextUtils; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.metadata.id3.PrivFrame; import com.google.android.exoplayer2.source.chunk.MediaChunk; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.concurrent.atomic.AtomicInteger; @@ -34,6 +48,17 @@ import java.util.concurrent.atomic.AtomicInteger; private static final AtomicInteger UID_SOURCE = new AtomicInteger(); + private static final String PRIV_TIMESTAMP_FRAME_OWNER = + "com.apple.streaming.transportStreamTimestamp"; + + private static final String AAC_FILE_EXTENSION = ".aac"; + private static final String AC3_FILE_EXTENSION = ".ac3"; + private static final String EC3_FILE_EXTENSION = ".ec3"; + private static final String MP3_FILE_EXTENSION = ".mp3"; + private static final String MP4_FILE_EXTENSION = ".mp4"; + private static final String VTT_FILE_EXTENSION = ".vtt"; + private static final String WEBVTT_FILE_EXTENSION = ".webvtt"; + /** * A unique identifier for the chunk. */ @@ -45,58 +70,76 @@ import java.util.concurrent.atomic.AtomicInteger; public final int discontinuitySequenceNumber; /** - * The extractor into which this chunk is being consumed. + * The url of the playlist from which this chunk was obtained. */ - public final Extractor extractor; + public final HlsUrl hlsUrl; + private final DataSource initDataSource; + private final DataSpec initDataSpec; private final boolean isEncrypted; - private final boolean extractorNeedsInit; - private final boolean shouldSpliceIn; private final boolean isMasterTimestampSource; private final TimestampAdjuster timestampAdjuster; + private final HlsMediaChunk previousChunk; + private final String lastPathSegment; + private final boolean isPackedAudio; + private final Id3Decoder id3Decoder; + private final ParsableByteArray id3Data; + + private Extractor extractor; + private int initSegmentBytesLoaded; private int bytesLoaded; + private boolean initLoadCompleted; private HlsSampleStreamWrapper extractorOutput; - private long adjustedEndTimeUs; private volatile boolean loadCanceled; private volatile boolean loadCompleted; /** * @param dataSource The source from which the data should be loaded. * @param dataSpec Defines the data to be loaded. - * @param trackFormat See {@link #trackFormat}. + * @param initDataSpec Defines the initialization data to be fed to new extractors. May be null. + * @param hlsUrl The url of the playlist from which this chunk was obtained. * @param trackSelectionReason See {@link #trackSelectionReason}. * @param trackSelectionData See {@link #trackSelectionData}. - * @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 startTimeUs The start time of the chunk in microseconds. + * @param endTimeUs The end time of the chunk in microseconds. * @param chunkIndex The media sequence number of the chunk. * @param discontinuitySequenceNumber The discontinuity sequence number of the chunk. * @param isMasterTimestampSource True if the chunk can initialize the timestamp adjuster. * @param timestampAdjuster Adjuster corresponding to the provided discontinuity sequence number. - * @param extractor The extractor to decode samples from the data. - * @param extractorNeedsInit Whether the extractor needs initializing with the target - * {@link HlsSampleStreamWrapper}. - * @param shouldSpliceIn Whether the samples parsed from this chunk should be spliced into any - * samples already queued to the {@link HlsSampleStreamWrapper}. + * @param previousChunk The {@link HlsMediaChunk} that preceded this one. May be null. * @param encryptionKey For AES encryption chunks, the encryption key. * @param encryptionIv For AES encryption chunks, the encryption initialization vector. */ - public HlsMediaChunk(DataSource dataSource, DataSpec dataSpec, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, long startTimeUs, long endTimeUs, - int chunkIndex, int discontinuitySequenceNumber, boolean isMasterTimestampSource, - TimestampAdjuster timestampAdjuster, Extractor extractor, boolean extractorNeedsInit, - boolean shouldSpliceIn, byte[] encryptionKey, byte[] encryptionIv) { - super(buildDataSource(dataSource, encryptionKey, encryptionIv), dataSpec, trackFormat, + public HlsMediaChunk(DataSource dataSource, DataSpec dataSpec, DataSpec initDataSpec, + HlsUrl hlsUrl, int trackSelectionReason, Object trackSelectionData, long startTimeUs, + long endTimeUs, int chunkIndex, int discontinuitySequenceNumber, + boolean isMasterTimestampSource, TimestampAdjuster timestampAdjuster, + HlsMediaChunk previousChunk, byte[] encryptionKey, byte[] encryptionIv) { + super(buildDataSource(dataSource, encryptionKey, encryptionIv), dataSpec, hlsUrl.format, trackSelectionReason, trackSelectionData, startTimeUs, endTimeUs, chunkIndex); - this.discontinuitySequenceNumber = discontinuitySequenceNumber; + this.initDataSpec = initDataSpec; + this.hlsUrl = hlsUrl; this.isMasterTimestampSource = isMasterTimestampSource; this.timestampAdjuster = timestampAdjuster; - this.extractor = extractor; - this.extractorNeedsInit = extractorNeedsInit; - this.shouldSpliceIn = shouldSpliceIn; + this.discontinuitySequenceNumber = discontinuitySequenceNumber; + this.previousChunk = previousChunk; // Note: this.dataSource and dataSource may be different. - adjustedEndTimeUs = startTimeUs; this.isEncrypted = this.dataSource instanceof Aes128DataSource; + lastPathSegment = dataSpec.uri.getLastPathSegment(); + isPackedAudio = lastPathSegment.endsWith(AAC_FILE_EXTENSION) + || lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION) + || lastPathSegment.endsWith(MP3_FILE_EXTENSION); + if (isPackedAudio) { + id3Decoder = previousChunk != null ? previousChunk.id3Decoder : new Id3Decoder(); + id3Data = previousChunk != null ? previousChunk.id3Data + : new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + } else { + id3Decoder = null; + id3Data = null; + } + initDataSource = dataSource; uid = UID_SOURCE.getAndIncrement(); } @@ -108,24 +151,7 @@ import java.util.concurrent.atomic.AtomicInteger; */ public void init(HlsSampleStreamWrapper output) { extractorOutput = output; - output.init(uid, shouldSpliceIn); - if (extractorNeedsInit) { - extractor.init(output); - } - } - - /** - * Returns the presentation time in microseconds of the first sample in the chunk. - */ - public long getAdjustedStartTimeUs() { - return adjustedEndTimeUs - getDurationUs(); - } - - /** - * Returns the presentation time in microseconds of the last sample in the chunk - */ - public long getAdjustedEndTimeUs() { - return adjustedEndTimeUs; + output.init(uid, previousChunk != null && previousChunk.hlsUrl != hlsUrl); } @Override @@ -152,6 +178,42 @@ import java.util.concurrent.atomic.AtomicInteger; @Override public void load() throws IOException, InterruptedException { + if (extractor == null && !isPackedAudio) { + // See HLS spec, version 20, Section 3.4 for more information on packed audio extraction. + extractor = buildExtractorByExtension(); + } + maybeLoadInitData(); + if (!loadCanceled) { + loadMedia(); + } + } + + // Internal loading methods. + + private void maybeLoadInitData() throws IOException, InterruptedException { + if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted + || initDataSpec == null) { + return; + } + DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); + try { + ExtractorInput input = new DefaultExtractorInput(initDataSource, + initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec)); + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, null); + } + } finally { + initSegmentBytesLoaded = (int) (input.getPosition() - initDataSpec.absoluteStreamPosition); + } + } finally { + Util.closeQuietly(dataSource); + } + initLoadCompleted = true; + } + + private void loadMedia() throws IOException, InterruptedException { // If we previously fed part of this chunk to the extractor, we need to skip it this time. For // encrypted content we need to skip the data by reading it through the source, so as to ensure // correct decryption of the remainder of the chunk. For clear content, we can request the @@ -165,34 +227,88 @@ import java.util.concurrent.atomic.AtomicInteger; loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded); skipLoadedBytes = false; } + if (!isMasterTimestampSource) { + timestampAdjuster.waitUntilInitialized(); + } try { ExtractorInput input = new DefaultExtractorInput(dataSource, loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + if (extractor == null) { + // Media segment format is packed audio. + long id3Timestamp = peekId3PrivTimestamp(input); + if (id3Timestamp == C.TIME_UNSET) { + throw new ParserException("ID3 PRIV timestamp missing."); + } + extractor = buildPackedAudioExtractor(timestampAdjuster.adjustTsTimestamp(id3Timestamp)); + } if (skipLoadedBytes) { input.skipFully(bytesLoaded); } try { int result = Extractor.RESULT_CONTINUE; - if (!isMasterTimestampSource && timestampAdjuster != null) { - timestampAdjuster.waitUntilInitialized(); - } while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { result = extractor.read(input, null); } - long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs(); - if (adjustedEndTimeUs != Long.MIN_VALUE) { - this.adjustedEndTimeUs = adjustedEndTimeUs; - } } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } loadCompleted = true; } - // Private methods + /** + * Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined + * in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not + * found. This method only modifies the peek position. + * + * @param input The {@link ExtractorInput} to obtain the PRIV frame from. + * @return The parsed, adjusted timestamp in microseconds + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + if (!input.peekFully(id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH, true)) { + return C.TIME_UNSET; + } + id3Data.reset(Id3Decoder.ID3_HEADER_LENGTH); + int id = id3Data.readUnsignedInt24(); + if (id != Id3Decoder.ID3_TAG) { + return C.TIME_UNSET; + } + id3Data.skipBytes(3); // version(2), flags(1). + int id3Size = id3Data.readSynchSafeInt(); + int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH; + if (requiredCapacity > id3Data.capacity()) { + byte[] data = id3Data.data; + id3Data.reset(requiredCapacity); + System.arraycopy(data, 0, id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } + if (!input.peekFully(id3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size, true)) { + return C.TIME_UNSET; + } + Metadata metadata = id3Decoder.decode(id3Data.data, id3Size); + if (metadata == null) { + return C.TIME_UNSET; + } + int metadataLength = metadata.length(); + for (int i = 0; i < metadataLength; i++) { + Metadata.Entry frame = metadata.get(i); + if (frame instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) frame; + if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { + System.arraycopy(privFrame.privateData, 0, id3Data.data, 0, 8 /* timestamp size */); + id3Data.reset(8); + return id3Data.readLong(); + } + } + } + return C.TIME_UNSET; + } + + // Internal factory methods. /** * If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in @@ -206,4 +322,62 @@ import java.util.concurrent.atomic.AtomicInteger; return new Aes128DataSource(dataSource, encryptionKey, encryptionIv); } + private Extractor buildExtractorByExtension() { + // Set the extractor that will read the chunk. + Extractor extractor; + boolean needNewExtractor = previousChunk == null + || previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber + || trackFormat != previousChunk.trackFormat; + boolean usingNewExtractor = true; + if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) + || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { + extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster); + } else if (!needNewExtractor) { + // Only reuse TS and fMP4 extractors. + usingNewExtractor = false; + extractor = previousChunk.extractor; + } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION)) { + extractor = new FragmentedMp4Extractor(0, timestampAdjuster); + } else { + // MPEG-2 TS segments, but we need a new extractor. + // This flag ensures the change of pid between streams does not affect the sample queues. + @DefaultTsPayloadReaderFactory.Flags + int esReaderFactoryFlags = DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM; + String codecs = trackFormat.codecs; + if (!TextUtils.isEmpty(codecs)) { + // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really + // exist. If we know from the codec attribute that they don't exist, then we can + // explicitly ignore them even if they're declared. + if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { + esReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM; + } + if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { + esReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM; + } + } + extractor = new TsExtractor(timestampAdjuster, + new DefaultTsPayloadReaderFactory(esReaderFactoryFlags), true); + } + if (usingNewExtractor) { + extractor.init(extractorOutput); + } + return extractor; + } + + private Extractor buildPackedAudioExtractor(long startTimeUs) { + Extractor extractor; + if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { + extractor = new AdtsExtractor(startTimeUs); + } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { + extractor = new Ac3Extractor(startTimeUs); + } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { + extractor = new Mp3Extractor(startTimeUs); + } else { + throw new IllegalArgumentException("Unkown extension for audio file: " + lastPathSegment); + } + extractor.init(extractorOutput); + return extractor; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index f4c8177f21..6082372b05 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -15,30 +15,22 @@ */ package com.google.android.exoplayer2.source.hls; -import android.net.Uri; import android.os.Handler; import android.text.TextUtils; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.ParserException; -import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.CompositeSequenceableLoader; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SampleStream; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.Loader; -import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; import java.util.ArrayList; @@ -48,61 +40,44 @@ import java.util.List; /** * A {@link MediaPeriod} that loads an HLS stream. */ -/* package */ final class HlsMediaPeriod implements MediaPeriod, - Loader.Callback>, HlsSampleStreamWrapper.Callback { +public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper.Callback, + HlsPlaylistTracker.PlaylistEventListener { - private final Uri manifestUri; + private final HlsPlaylistTracker playlistTracker; private final DataSource.Factory dataSourceFactory; private final int minLoadableRetryCount; private final EventDispatcher eventDispatcher; - private final MediaSource.Listener sourceListener; private final Allocator allocator; private final IdentityHashMap streamWrapperIndices; private final TimestampAdjusterProvider timestampAdjusterProvider; - private final HlsPlaylistParser manifestParser; private final Handler continueLoadingHandler; - private final Loader manifestFetcher; private final long preparePositionUs; - private final Runnable continueLoadingRunnable; private Callback callback; private int pendingPrepareCount; - private HlsPlaylist playlist; private boolean seenFirstTrackSelection; - private long durationUs; - private boolean isLive; private TrackGroupArray trackGroups; private HlsSampleStreamWrapper[] sampleStreamWrappers; private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; private CompositeSequenceableLoader sequenceableLoader; - public HlsMediaPeriod(Uri manifestUri, DataSource.Factory dataSourceFactory, - int minLoadableRetryCount, EventDispatcher eventDispatcher, - MediaSource.Listener sourceListener, Allocator allocator, + public HlsMediaPeriod(HlsPlaylistTracker playlistTracker, DataSource.Factory dataSourceFactory, + int minLoadableRetryCount, EventDispatcher eventDispatcher, Allocator allocator, long positionUs) { - this.manifestUri = manifestUri; + this.playlistTracker = playlistTracker; this.dataSourceFactory = dataSourceFactory; this.minLoadableRetryCount = minLoadableRetryCount; this.eventDispatcher = eventDispatcher; - this.sourceListener = sourceListener; this.allocator = allocator; streamWrapperIndices = new IdentityHashMap<>(); timestampAdjusterProvider = new TimestampAdjusterProvider(); - manifestParser = new HlsPlaylistParser(); continueLoadingHandler = new Handler(); - manifestFetcher = new Loader("Loader:ManifestFetcher"); preparePositionUs = positionUs; - continueLoadingRunnable = new Runnable() { - @Override - public void run() { - callback.onContinueLoadingRequested(HlsMediaPeriod.this); - } - }; } public void release() { + playlistTracker.removeListener(this); continueLoadingHandler.removeCallbacksAndMessages(null); - manifestFetcher.release(); if (sampleStreamWrappers != null) { for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { sampleStreamWrapper.release(); @@ -112,18 +87,14 @@ import java.util.List; @Override public void prepare(Callback callback) { + playlistTracker.addListener(this); this.callback = callback; - ParsingLoadable loadable = new ParsingLoadable<>( - dataSourceFactory.createDataSource(), manifestUri, C.DATA_TYPE_MANIFEST, manifestParser); - long elapsedRealtimeMs = manifestFetcher.startLoading(loadable, this, minLoadableRetryCount); - eventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs); + buildAndPrepareSampleStreamWrappers(); } @Override public void maybeThrowPrepareError() throws IOException { - if (sampleStreamWrappers == null) { - manifestFetcher.maybeThrowError(); - } else { + if (sampleStreamWrappers != null) { for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { sampleStreamWrapper.maybeThrowPrepareError(); } @@ -192,6 +163,18 @@ import java.util.List; // Update the local state. enabledSampleStreamWrappers = new HlsSampleStreamWrapper[enabledSampleStreamWrapperList.size()]; enabledSampleStreamWrapperList.toArray(enabledSampleStreamWrappers); + + // The first enabled sample stream wrapper is responsible for intializing the timestamp + // adjuster. This way, if present, variants are responsible. Otherwise, audio renditions are. + // If only subtitles are present, then text renditions are used for timestamp adjustment + // initialization. + if (enabledSampleStreamWrappers.length > 0) { + enabledSampleStreamWrappers[0].setIsTimestampMaster(true); + for (int i = 1; i < enabledSampleStreamWrappers.length; i++) { + enabledSampleStreamWrappers[i].setIsTimestampMaster(false); + } + } + sequenceableLoader = new CompositeSequenceableLoader(enabledSampleStreamWrappers); if (seenFirstTrackSelection && selectedNewTracks) { seekToUs(positionUs); @@ -235,8 +218,6 @@ import java.util.List; @Override public long seekToUs(long positionUs) { - // Treat all seeks into non-seekable media as being to t=0. - positionUs = isLive ? 0 : positionUs; timestampAdjusterProvider.reset(); for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) { sampleStreamWrapper.seekTo(positionUs); @@ -244,33 +225,6 @@ import java.util.List; return positionUs; } - // Loader.Callback implementation. - - @Override - public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs) { - eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, - loadDurationMs, loadable.bytesLoaded()); - playlist = loadable.getResult(); - buildAndPrepareSampleStreamWrappers(); - } - - @Override - public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, boolean released) { - eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, - loadDurationMs, loadable.bytesLoaded()); - } - - @Override - public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, IOException error) { - boolean isFatal = error instanceof ParserException; - eventDispatcher.loadError(loadable.dataSpec, loadable.type, elapsedRealtimeMs, loadDurationMs, - loadable.bytesLoaded(), error, isFatal); - return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; - } - // HlsSampleStreamWrapper.Callback implementation. @Override @@ -279,10 +233,6 @@ import java.util.List; return; } - // The wrapper at index 0 is the one of type TRACK_TYPE_DEFAULT. - durationUs = sampleStreamWrappers[0].getDurationUs(); - isLive = sampleStreamWrappers[0].isLive(); - int totalTrackGroupCount = 0; for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { totalTrackGroupCount += sampleStreamWrapper.getTrackGroups().length; @@ -297,16 +247,11 @@ import java.util.List; } trackGroups = new TrackGroupArray(trackGroupArray); callback.onPrepared(this); - - // TODO[playlists]: Calculate the window. - Timeline timeline = new SinglePeriodTimeline(durationUs, durationUs, 0, 0, !isLive, isLive); - sourceListener.onSourceInfoRefreshed(timeline, playlist); } @Override - public void onContinueLoadingRequiredInMs(final HlsSampleStreamWrapper sampleStreamWrapper, - long delayMs) { - continueLoadingHandler.postDelayed(continueLoadingRunnable, delayMs); + public void onPlaylistRefreshRequired(HlsUrl url) { + playlistTracker.refreshPlaylist(url); } @Override @@ -318,28 +263,31 @@ import java.util.List; callback.onContinueLoadingRequested(this); } + // PlaylistListener implementation. + + @Override + public void onPlaylistChanged() { + continuePreparingOrLoading(); + } + + @Override + public void onPlaylistBlacklisted(HlsUrl url, long blacklistMs) { + for (HlsSampleStreamWrapper streamWrapper : sampleStreamWrappers) { + streamWrapper.onPlaylistBlacklisted(url, blacklistMs); + } + continuePreparingOrLoading(); + } + // Internal methods. private void buildAndPrepareSampleStreamWrappers() { - String baseUri = playlist.baseUri; - if (playlist instanceof HlsMediaPlaylist) { - HlsMasterPlaylist.HlsUrl[] variants = new HlsMasterPlaylist.HlsUrl[] { - HlsMasterPlaylist.HlsUrl.createMediaPlaylistHlsUrl(playlist.baseUri)}; - sampleStreamWrappers = new HlsSampleStreamWrapper[] { - buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, baseUri, variants, null, null)}; - pendingPrepareCount = 1; - sampleStreamWrappers[0].continuePreparing(); - return; - } - - HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; - + HlsMasterPlaylist masterPlaylist = playlistTracker.getMasterPlaylist(); // Build the default stream wrapper. - List selectedVariants = new ArrayList<>(masterPlaylist.variants); - ArrayList definiteVideoVariants = new ArrayList<>(); - ArrayList definiteAudioOnlyVariants = new ArrayList<>(); + List selectedVariants = new ArrayList<>(masterPlaylist.variants); + ArrayList definiteVideoVariants = new ArrayList<>(); + ArrayList definiteAudioOnlyVariants = new ArrayList<>(); for (int i = 0; i < selectedVariants.size(); i++) { - HlsMasterPlaylist.HlsUrl variant = selectedVariants.get(i); + HlsUrl variant = selectedVariants.get(i); if (variant.format.height > 0 || variantHasExplicitCodecWithPrefix(variant, "avc")) { definiteVideoVariants.add(variant); } else if (variantHasExplicitCodecWithPrefix(variant, "mp4a")) { @@ -358,51 +306,64 @@ import java.util.List; } else { // Leave the enabled variants unchanged. They're likely either all video or all audio. } - List audioVariants = masterPlaylist.audios; - List subtitleVariants = masterPlaylist.subtitles; - sampleStreamWrappers = new HlsSampleStreamWrapper[(selectedVariants.isEmpty() ? 0 : 1) - + audioVariants.size() + subtitleVariants.size()]; + List audioRenditions = masterPlaylist.audios; + List subtitleRenditions = masterPlaylist.subtitles; + sampleStreamWrappers = new HlsSampleStreamWrapper[1 /* variants */ + audioRenditions.size() + + subtitleRenditions.size()]; int currentWrapperIndex = 0; pendingPrepareCount = sampleStreamWrappers.length; - if (!selectedVariants.isEmpty()) { - HlsMasterPlaylist.HlsUrl[] variants = new HlsMasterPlaylist.HlsUrl[selectedVariants.size()]; - selectedVariants.toArray(variants); - HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, - baseUri, variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormat); - sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; - sampleStreamWrapper.continuePreparing(); - } + + Assertions.checkArgument(!selectedVariants.isEmpty()); + HlsUrl[] variants = new HlsMasterPlaylist.HlsUrl[selectedVariants.size()]; + selectedVariants.toArray(variants); + HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, + variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormat); + sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; + sampleStreamWrapper.setIsTimestampMaster(true); + sampleStreamWrapper.continuePreparing(); + + // TODO: Build video stream wrappers here. // Build audio stream wrappers. - for (int i = 0; i < audioVariants.size(); i++) { - HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_AUDIO, - baseUri, new HlsMasterPlaylist.HlsUrl[] {audioVariants.get(i)}, null, null); + for (int i = 0; i < audioRenditions.size(); i++) { + sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_AUDIO, + new HlsUrl[] {audioRenditions.get(i)}, null, null); sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; sampleStreamWrapper.continuePreparing(); } // Build subtitle stream wrappers. - for (int i = 0; i < subtitleVariants.size(); i++) { - HlsMasterPlaylist.HlsUrl url = subtitleVariants.get(i); - HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_TEXT, - baseUri, new HlsMasterPlaylist.HlsUrl[] {url}, null, null); + for (int i = 0; i < subtitleRenditions.size(); i++) { + HlsUrl url = subtitleRenditions.get(i); + sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_TEXT, new HlsUrl[] {url}, null, + null); sampleStreamWrapper.prepareSingleTrack(url.format); sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; } } - private HlsSampleStreamWrapper buildSampleStreamWrapper(int trackType, String baseUri, - HlsMasterPlaylist.HlsUrl[] variants, Format muxedAudioFormat, Format muxedCaptionFormat) { + private HlsSampleStreamWrapper buildSampleStreamWrapper(int trackType, HlsUrl[] variants, + Format muxedAudioFormat, Format muxedCaptionFormat) { DataSource dataSource = dataSourceFactory.createDataSource(); - HlsChunkSource defaultChunkSource = new HlsChunkSource(baseUri, variants, dataSource, + HlsChunkSource defaultChunkSource = new HlsChunkSource(playlistTracker, variants, dataSource, timestampAdjusterProvider); return new HlsSampleStreamWrapper(trackType, this, defaultChunkSource, allocator, preparePositionUs, muxedAudioFormat, muxedCaptionFormat, minLoadableRetryCount, eventDispatcher); } - private static boolean variantHasExplicitCodecWithPrefix(HlsMasterPlaylist.HlsUrl variant, - String prefix) { + private void continuePreparingOrLoading() { + if (trackGroups != null) { + callback.onContinueLoadingRequested(this); + } else { + // Some of the wrappers were waiting for their media playlist to prepare. + for (HlsSampleStreamWrapper wrapper : sampleStreamWrappers) { + wrapper.continuePreparing(); + } + } + } + + private static boolean variantHasExplicitCodecWithPrefix(HlsUrl variant, String prefix) { String codecs = variant.format.codecs; if (TextUtils.isEmpty(codecs)) { return false; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index b8b6c033b3..2f46fc694c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -18,19 +18,25 @@ package com.google.android.exoplayer2.source.hls; import android.net.Uri; import android.os.Handler; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.List; /** * An HLS {@link MediaSource}. */ -public final class HlsMediaSource implements MediaSource { +public final class HlsMediaSource implements MediaSource, + HlsPlaylistTracker.PrimaryPlaylistListener { /** * The default minimum number of times to retry loading data prior to failing. @@ -42,7 +48,8 @@ public final class HlsMediaSource implements MediaSource { private final int minLoadableRetryCount; private final EventDispatcher eventDispatcher; - private MediaSource.Listener sourceListener; + private HlsPlaylistTracker playlistTracker; + private Listener sourceListener; public HlsMediaSource(Uri manifestUri, DataSource.Factory dataSourceFactory, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { @@ -60,22 +67,24 @@ public final class HlsMediaSource implements MediaSource { } @Override - public void prepareSource(MediaSource.Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + Assertions.checkState(playlistTracker == null); + playlistTracker = new HlsPlaylistTracker(manifestUri, dataSourceFactory, eventDispatcher, + minLoadableRetryCount, this); sourceListener = listener; - // TODO: Defer until the playlist has been loaded. - listener.onSourceInfoRefreshed(new SinglePeriodTimeline(C.TIME_UNSET, false), null); + playlistTracker.start(); } @Override - public void maybeThrowSourceInfoRefreshError() { - // Do nothing. + public void maybeThrowSourceInfoRefreshError() throws IOException { + playlistTracker.maybeThrowPlaylistRefreshError(); } @Override public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { Assertions.checkArgument(index == 0); - return new HlsMediaPeriod(manifestUri, dataSourceFactory, minLoadableRetryCount, - eventDispatcher, sourceListener, allocator, positionUs); + return new HlsMediaPeriod(playlistTracker, dataSourceFactory, minLoadableRetryCount, + eventDispatcher, allocator, positionUs); } @Override @@ -85,7 +94,27 @@ public final class HlsMediaSource implements MediaSource { @Override public void releaseSource() { + playlistTracker.release(); + playlistTracker = null; sourceListener = null; } + @Override + public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { + SinglePeriodTimeline timeline; + if (playlistTracker.isLive()) { + // TODO: fix windowPositionInPeriodUs when playlist is empty. + long windowPositionInPeriodUs = playlist.startTimeUs; + List segments = playlist.segments; + long windowDefaultStartPositionUs = segments.isEmpty() ? 0 + : segments.get(Math.max(0, segments.size() - 3)).relativeStartTimeUs; + timeline = new SinglePeriodTimeline(C.TIME_UNSET, playlist.durationUs, + windowPositionInPeriodUs, windowDefaultStartPositionUs, true, !playlist.hasEndTag); + } else /* not live */ { + timeline = new SinglePeriodTimeline(playlist.durationUs, playlist.durationUs, 0, 0, true, + false); + } + sourceListener.onSourceInfoRefreshed(timeline, playlist); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index fe756da0ef..a9bbddb69c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -32,6 +32,8 @@ import com.google.android.exoplayer2.source.SequenceableLoader; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.chunk.Chunk; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.Loader; @@ -58,10 +60,10 @@ import java.util.LinkedList; void onPrepared(); /** - * Called to schedule a {@link #continueLoading(long)} call. + * Called to schedule a {@link #continueLoading(long)} call when the playlist referred by the + * given url changes. */ - void onContinueLoadingRequiredInMs(HlsSampleStreamWrapper sampleStreamSource, - long delayMs); + void onPlaylistRefreshRequired(HlsMasterPlaylist.HlsUrl playlistUrl); } @@ -164,14 +166,6 @@ import java.util.LinkedList; maybeThrowError(); } - public long getDurationUs() { - return chunkSource.getDurationUs(); - } - - public boolean isLive() { - return chunkSource.isLive(); - } - public TrackGroupArray getTrackGroups() { return trackGroups; } @@ -281,6 +275,14 @@ import java.util.LinkedList; return largestQueuedTimestampUs; } + public void setIsTimestampMaster(boolean isTimestampMaster) { + chunkSource.setIsTimestampMaster(isTimestampMaster); + } + + public void onPlaylistBlacklisted(HlsUrl url, long blacklistMs) { + chunkSource.onPlaylistBlacklisted(url, blacklistMs); + } + // SampleStream implementation. /* package */ boolean isReady(int group) { @@ -331,7 +333,7 @@ import java.util.LinkedList; @Override public boolean continueLoading(long positionUs) { - if (loader.isLoading()) { + if (loadingFinished || loader.isLoading()) { return false; } @@ -340,7 +342,7 @@ import java.util.LinkedList; nextChunkHolder); boolean endOfStream = nextChunkHolder.endOfStream; Chunk loadable = nextChunkHolder.chunk; - long retryInMs = nextChunkHolder.retryInMs; + HlsMasterPlaylist.HlsUrl playlistToLoad = nextChunkHolder.playlist; nextChunkHolder.clear(); if (endOfStream) { @@ -349,9 +351,8 @@ import java.util.LinkedList; } if (loadable == null) { - if (retryInMs != C.TIME_UNSET) { - Assertions.checkState(chunkSource.isLive()); - callback.onContinueLoadingRequiredInMs(this, retryInMs); + if (playlistToLoad != null) { + callback.onPlaylistRefreshRequired(playlistToLoad); } return false; } @@ -361,8 +362,6 @@ import java.util.LinkedList; HlsMediaChunk mediaChunk = (HlsMediaChunk) loadable; mediaChunk.init(this); mediaChunks.add(mediaChunk); - } else if (loadable instanceof HlsInitializationChunk) { - ((HlsInitializationChunk) loadable).init(this); } long elapsedRealtimeMs = loader.startLoading(loadable, this, minLoadableRetryCount); eventDispatcher.loadStarted(loadable.dataSpec, loadable.type, trackType, loadable.trackFormat, diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java index f565fdf9ea..498dd55004 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java @@ -79,7 +79,7 @@ import java.util.regex.Pattern; } @Override - public void seek(long position) { + public void seek(long position, long timeUs) { // This extractor is only used for the HLS use case, which should not call this method. throw new IllegalStateException(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java index c0d4890b44..4aaec59f7d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java @@ -73,4 +73,10 @@ public final class HlsMasterPlaylist extends HlsPlaylist { this.muxedCaptionFormat = muxedCaptionFormat; } + public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUri) { + List variant = Collections.singletonList(HlsUrl.createMediaPlaylistHlsUrl(variantUri)); + List emptyList = Collections.emptyList(); + return new HlsMasterPlaylist(null, variant, emptyList, emptyList, null, null); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 5aa0c8a3d8..fc70ec6de1 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source.hls.playlist; import com.google.android.exoplayer2.C; +import java.util.Collections; import java.util.List; /** @@ -29,9 +30,9 @@ public final class HlsMediaPlaylist extends HlsPlaylist { public static final class Segment implements Comparable { public final String url; - public final double durationSecs; + public final long durationUs; public final int discontinuitySequenceNumber; - public final long startTimeUs; + public final long relativeStartTimeUs; public final boolean isEncrypted; public final String encryptionKeyUri; public final String encryptionIV; @@ -42,13 +43,13 @@ public final class HlsMediaPlaylist extends HlsPlaylist { this(uri, 0, -1, C.TIME_UNSET, false, null, null, byterangeOffset, byterangeLength); } - public Segment(String uri, double durationSecs, int discontinuitySequenceNumber, - long startTimeUs, boolean isEncrypted, String encryptionKeyUri, String encryptionIV, + public Segment(String uri, long durationUs, int discontinuitySequenceNumber, + long relativeStartTimeUs, boolean isEncrypted, String encryptionKeyUri, String encryptionIV, long byterangeOffset, long byterangeLength) { this.url = uri; - this.durationSecs = durationSecs; + this.durationUs = durationUs; this.discontinuitySequenceNumber = discontinuitySequenceNumber; - this.startTimeUs = startTimeUs; + this.relativeStartTimeUs = relativeStartTimeUs; this.isEncrypted = isEncrypted; this.encryptionKeyUri = encryptionKeyUri; this.encryptionIV = encryptionIV; @@ -57,38 +58,57 @@ public final class HlsMediaPlaylist extends HlsPlaylist { } @Override - public int compareTo(Long startTimeUs) { - return this.startTimeUs > startTimeUs ? 1 : (this.startTimeUs < startTimeUs ? -1 : 0); + public int compareTo(Long relativeStartTimeUs) { + return this.relativeStartTimeUs > relativeStartTimeUs + ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0); } + } - public static final String ENCRYPTION_METHOD_NONE = "NONE"; - public static final String ENCRYPTION_METHOD_AES_128 = "AES-128"; - + public final long startTimeUs; public final int mediaSequence; - public final int targetDurationSecs; public final int version; + public final long targetDurationUs; + public final boolean hasEndTag; + public final boolean hasProgramDateTime; public final Segment initializationSegment; public final List segments; - public final boolean live; public final long durationUs; - public HlsMediaPlaylist(String baseUri, int mediaSequence, int targetDurationSecs, int version, - boolean live, Segment initializationSegment, List segments) { + public HlsMediaPlaylist(String baseUri, long startTimeUs, int mediaSequence, + int version, long targetDurationUs, boolean hasEndTag, boolean hasProgramDateTime, + Segment initializationSegment, List segments) { super(baseUri, HlsPlaylist.TYPE_MEDIA); + this.startTimeUs = startTimeUs; this.mediaSequence = mediaSequence; - this.targetDurationSecs = targetDurationSecs; this.version = version; - this.live = live; + this.targetDurationUs = targetDurationUs; + this.hasEndTag = hasEndTag; + this.hasProgramDateTime = hasProgramDateTime; this.initializationSegment = initializationSegment; - this.segments = segments; + this.segments = Collections.unmodifiableList(segments); if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); - durationUs = last.startTimeUs + (long) (last.durationSecs * C.MICROS_PER_SECOND); + durationUs = last.relativeStartTimeUs + last.durationUs; } else { durationUs = 0; } } + public boolean isNewerThan(HlsMediaPlaylist other) { + return other == null || mediaSequence > other.mediaSequence + || (mediaSequence == other.mediaSequence && segments.size() > other.segments.size()) + || (hasEndTag && !other.hasEndTag); + } + + public long getEndTimeUs() { + return startTimeUs + durationUs; + } + + public HlsMediaPlaylist copyWithStartTimeUs(long startTimeUs) { + return new HlsMediaPlaylist(baseUri, startTimeUs, mediaSequence, version, targetDurationUs, + hasEndTag, hasProgramDateTime, initializationSegment, segments); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 21cc75765f..1932caccf7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -22,12 +22,12 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; 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; @@ -44,6 +44,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser segments = new ArrayList<>(); - double segmentDurationSecs = 0.0; + long segmentDurationUs = 0; int discontinuitySequenceNumber = 0; + long playlistStartTimeUs = 0; long segmentStartTimeUs = 0; long segmentByteRangeOffset = 0; long segmentByteRangeLength = C.LENGTH_UNSET; @@ -246,14 +243,15 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser> { + + /** + * Listener for primary playlist changes. + */ + public interface PrimaryPlaylistListener { + + /** + * Called when the primary playlist changes. + * + * @param mediaPlaylist The primary playlist new snapshot. + */ + void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist); + + } + + /** + * Called on playlist loading events. + */ + public interface PlaylistEventListener { + + /** + * Called a playlist changes. + */ + void onPlaylistChanged(); + + /** + * Called if an error is encountered while loading a playlist. + * + * @param url The loaded url that caused the error. + * @param blacklistDurationMs The number of milliseconds for which the playlist has been + * blacklisted. + */ + void onPlaylistBlacklisted(HlsUrl url, long blacklistDurationMs); + + } + + /** + * The minimum number of milliseconds that a url is kept as primary url, if no + * {@link #getPlaylistSnapshot} call is made for that url. + */ + private static final long PRIMARY_URL_KEEPALIVE_MS = 15000; + + private final Uri initialPlaylistUri; + private final DataSource.Factory dataSourceFactory; + private final HlsPlaylistParser playlistParser; + private final int minRetryCount; + private final IdentityHashMap playlistBundles; + private final Handler playlistRefreshHandler; + private final PrimaryPlaylistListener primaryPlaylistListener; + private final List listeners; + private final Loader initialPlaylistLoader; + private final EventDispatcher eventDispatcher; + + private HlsMasterPlaylist masterPlaylist; + private HlsUrl primaryHlsUrl; + private HlsMediaPlaylist primaryUrlSnapshot; + private boolean isLive; + + /** + * @param initialPlaylistUri Uri for the initial playlist of the stream. Can refer a media + * playlist or a master playlist. + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param eventDispatcher A dispatcher to notify of events. + * @param minRetryCount The minimum number of times the load must be retried before blacklisting a + * playlist. + * @param primaryPlaylistListener A callback for the primary playlist change events. + */ + public HlsPlaylistTracker(Uri initialPlaylistUri, DataSource.Factory dataSourceFactory, + EventDispatcher eventDispatcher, int minRetryCount, + PrimaryPlaylistListener primaryPlaylistListener) { + this.initialPlaylistUri = initialPlaylistUri; + this.dataSourceFactory = dataSourceFactory; + this.eventDispatcher = eventDispatcher; + this.minRetryCount = minRetryCount; + this.primaryPlaylistListener = primaryPlaylistListener; + listeners = new ArrayList<>(); + initialPlaylistLoader = new Loader("HlsPlaylistTracker:MasterPlaylist"); + playlistParser = new HlsPlaylistParser(); + playlistBundles = new IdentityHashMap<>(); + playlistRefreshHandler = new Handler(); + } + + /** + * Registers a listener to receive events from the playlist tracker. + * + * @param listener The listener. + */ + public void addListener(PlaylistEventListener listener) { + listeners.add(listener); + } + + /** + * Unregisters a listener. + * + * @param listener The listener to unregister. + */ + public void removeListener(PlaylistEventListener listener) { + listeners.remove(listener); + } + + /** + * Starts tracking all the playlists related to the provided Uri. + */ + public void start() { + ParsingLoadable masterPlaylistLoadable = new ParsingLoadable<>( + dataSourceFactory.createDataSource(), initialPlaylistUri, C.DATA_TYPE_MANIFEST, + playlistParser); + initialPlaylistLoader.startLoading(masterPlaylistLoadable, this, minRetryCount); + } + + /** + * Returns the master playlist. + * + * @return The master playlist. Null if the initial playlist has yet to be loaded. + */ + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + /** + * Returns the most recent snapshot available of the playlist referenced by the provided + * {@link HlsUrl}. + * + * @param url The {@link HlsUrl} corresponding to the requested media playlist. + * @return The most recent snapshot of the playlist referenced by the provided {@link HlsUrl}. May + * be null if no snapshot has been loaded yet. + */ + public HlsMediaPlaylist getPlaylistSnapshot(HlsUrl url) { + maybeSetPrimaryUrl(url); + return playlistBundles.get(url).getPlaylistSnapshot(); + } + + /** + * Releases the playlist tracker. + */ + public void release() { + initialPlaylistLoader.release(); + for (MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistBundles.clear(); + } + + /** + * If the tracker is having trouble refreshing the primary playlist or loading an irreplaceable + * playlist, this method throws the underlying error. Otherwise, does nothing. + * + * @throws IOException The underlying error. + */ + public void maybeThrowPlaylistRefreshError() throws IOException { + initialPlaylistLoader.maybeThrowError(); + if (primaryHlsUrl != null) { + playlistBundles.get(primaryHlsUrl).mediaPlaylistLoader.maybeThrowError(); + } + } + + /** + * Triggers a playlist refresh and whitelists it. + * + * @param url The {@link HlsUrl} of the playlist to be refreshed. + */ + public void refreshPlaylist(HlsUrl url) { + playlistBundles.get(url).loadPlaylist(); + } + + /** + * Returns whether this is live content. + * + * @return True if the content is live. False otherwise. + */ + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + HlsMasterPlaylist masterPlaylist; + boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri); + } else /* result instanceof HlsMasterPlaylist */ { + masterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = masterPlaylist; + primaryHlsUrl = masterPlaylist.variants.get(0); + ArrayList urls = new ArrayList<>(); + urls.addAll(masterPlaylist.variants); + urls.addAll(masterPlaylist.audios); + urls.addAll(masterPlaylist.subtitles); + createBundles(urls); + MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryHlsUrl); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result); + } else { + primaryBundle.loadPlaylist(); + } + eventDispatcher.loadCompleted(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, IOException error) { + boolean isFatal = error instanceof ParserException; + eventDispatcher.loadError(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded(), error, isFatal); + return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; + } + + // Internal methods. + + private boolean maybeSelectNewPrimaryUrl() { + List variants = masterPlaylist.variants; + int variantsSize = variants.size(); + long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < variantsSize; i++) { + MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i)); + if (currentTimeMs > bundle.blacklistUntilMs) { + primaryHlsUrl = bundle.playlistUrl; + bundle.loadPlaylist(); + return true; + } + } + return false; + } + + private void maybeSetPrimaryUrl(HlsUrl url) { + if (!masterPlaylist.variants.contains(url) + || (primaryUrlSnapshot != null && primaryUrlSnapshot.hasEndTag)) { + // Only allow variant urls to be chosen as primary. Also prevent changing the primary url if + // the last primary snapshot contains an end tag. + return; + } + MediaPlaylistBundle currentPrimaryBundle = playlistBundles.get(primaryHlsUrl); + long primarySnapshotAccessAgeMs = + currentPrimaryBundle.lastSnapshotAccessTimeMs - SystemClock.elapsedRealtime(); + if (primarySnapshotAccessAgeMs > PRIMARY_URL_KEEPALIVE_MS) { + primaryHlsUrl = url; + playlistBundles.get(primaryHlsUrl).loadPlaylist(); + } + } + + private void createBundles(List urls) { + int listSize = urls.size(); + long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < listSize; i++) { + HlsUrl url = urls.get(i); + MediaPlaylistBundle bundle = new MediaPlaylistBundle(url, currentTimeMs); + playlistBundles.put(urls.get(i), bundle); + } + } + + /** + * Called by the bundles when a snapshot changes. + * + * @param url The url of the playlist. + * @param newSnapshot The new snapshot. + * @return True if a refresh should be scheduled. + */ + private boolean onPlaylistUpdated(HlsUrl url, HlsMediaPlaylist newSnapshot) { + if (url == primaryHlsUrl) { + if (primaryUrlSnapshot == null) { + // This is the first primary url snapshot. + isLive = !newSnapshot.hasEndTag; + } + primaryUrlSnapshot = newSnapshot; + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot); + } + int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistChanged(); + } + // If the primary playlist is not the final one, we should schedule a refresh. + return url == primaryHlsUrl && !newSnapshot.hasEndTag; + } + + private void notifyPlaylistBlacklisting(HlsUrl url, long blacklistMs) { + int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistBlacklisted(url, blacklistMs); + } + } + + /** + * TODO: Track discontinuities for media playlists that don't include the discontinuity number. + */ + private HlsMediaPlaylist adjustPlaylistTimestamps(HlsMediaPlaylist oldPlaylist, + HlsMediaPlaylist newPlaylist) { + if (newPlaylist.hasProgramDateTime) { + if (newPlaylist.isNewerThan(oldPlaylist)) { + return newPlaylist; + } else { + return oldPlaylist; + } + } + // TODO: Once playlist type support is added, the snapshot's age can be added by using the + // target duration. + long primarySnapshotStartTimeUs = primaryUrlSnapshot != null + ? primaryUrlSnapshot.startTimeUs : 0; + if (oldPlaylist == null) { + if (newPlaylist.startTimeUs == primarySnapshotStartTimeUs) { + // Playback has just started or is VOD so no adjustment is needed. + return newPlaylist; + } else { + return newPlaylist.copyWithStartTimeUs(primarySnapshotStartTimeUs); + } + } + List oldSegments = oldPlaylist.segments; + int oldPlaylistSize = oldSegments.size(); + if (!newPlaylist.isNewerThan(oldPlaylist)) { + // Playlist has not changed. + return oldPlaylist; + } + int mediaSequenceOffset = newPlaylist.mediaSequence - oldPlaylist.mediaSequence; + if (mediaSequenceOffset <= oldPlaylistSize) { + long adjustedNewPlaylistStartTimeUs = mediaSequenceOffset == oldPlaylistSize + ? oldPlaylist.getEndTimeUs() + : oldPlaylist.startTimeUs + oldSegments.get(mediaSequenceOffset).relativeStartTimeUs; + return newPlaylist.copyWithStartTimeUs(adjustedNewPlaylistStartTimeUs); + } + // No segments overlap, we assume the new playlist start coincides with the primary playlist. + return newPlaylist.copyWithStartTimeUs(primarySnapshotStartTimeUs); + } + + /** + * Holds all information related to a specific Media Playlist. + */ + private final class MediaPlaylistBundle implements Loader.Callback>, + Runnable { + + private final HlsUrl playlistUrl; + private final Loader mediaPlaylistLoader; + private final ParsingLoadable mediaPlaylistLoadable; + + private HlsMediaPlaylist playlistSnapshot; + private long lastSnapshotAccessTimeMs; + private long blacklistUntilMs; + + public MediaPlaylistBundle(HlsUrl playlistUrl, long initialLastSnapshotAccessTimeMs) { + this.playlistUrl = playlistUrl; + lastSnapshotAccessTimeMs = initialLastSnapshotAccessTimeMs; + mediaPlaylistLoader = new Loader("HlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistLoadable = new ParsingLoadable<>(dataSourceFactory.createDataSource(), + UriUtil.resolveToUri(masterPlaylist.baseUri, playlistUrl.url), C.DATA_TYPE_MANIFEST, + playlistParser); + } + + public HlsMediaPlaylist getPlaylistSnapshot() { + lastSnapshotAccessTimeMs = SystemClock.elapsedRealtime(); + return playlistSnapshot; + } + + public void release() { + mediaPlaylistLoader.release(); + } + + public void loadPlaylist() { + blacklistUntilMs = 0; + if (!mediaPlaylistLoader.isLoading()) { + mediaPlaylistLoader.startLoading(mediaPlaylistLoadable, this, minRetryCount); + } + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + processLoadedPlaylist((HlsMediaPlaylist) loadable.getResult()); + eventDispatcher.loadCompleted(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, IOException error) { + boolean isFatal = error instanceof ParserException; + eventDispatcher.loadError(loadable.dataSpec, C.DATA_TYPE_MANIFEST, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded(), error, isFatal); + if (isFatal) { + return Loader.DONT_RETRY_FATAL; + } + boolean shouldRetry = true; + if (ChunkedTrackBlacklistUtil.shouldBlacklist(error)) { + blacklistUntilMs = + SystemClock.elapsedRealtime() + ChunkedTrackBlacklistUtil.DEFAULT_TRACK_BLACKLIST_MS; + notifyPlaylistBlacklisting(playlistUrl, + ChunkedTrackBlacklistUtil.DEFAULT_TRACK_BLACKLIST_MS); + shouldRetry = primaryHlsUrl == playlistUrl && !maybeSelectNewPrimaryUrl(); + } + return shouldRetry ? Loader.RETRY : Loader.DONT_RETRY; + } + + // Runnable implementation. + + @Override + public void run() { + loadPlaylist(); + } + + // Internal methods. + + private void processLoadedPlaylist(HlsMediaPlaylist loadedMediaPlaylist) { + HlsMediaPlaylist oldPlaylist = playlistSnapshot; + playlistSnapshot = adjustPlaylistTimestamps(oldPlaylist, loadedMediaPlaylist); + long refreshDelayUs = C.TIME_UNSET; + if (oldPlaylist != playlistSnapshot) { + if (onPlaylistUpdated(playlistUrl, playlistSnapshot)) { + refreshDelayUs = playlistSnapshot.targetDurationUs; + } + } else if (!loadedMediaPlaylist.hasEndTag) { + refreshDelayUs = playlistSnapshot.targetDurationUs / 2; + } + if (refreshDelayUs != C.TIME_UNSET) { + // See HLS spec v20, section 6.3.4 for more information on media playlist refreshing. + playlistRefreshHandler.postDelayed(this, C.usToMs(refreshDelayUs)); + } + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index f51280e0b9..aa197806e2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -218,7 +218,7 @@ public class DefaultSsChunkSource implements SsChunkSource { // To convert them the absolute timestamps, we need to set sampleOffsetUs to chunkStartTimeUs. long sampleOffsetUs = chunkStartTimeUs; return new ContainerMediaChunk(dataSource, dataSpec, format, trackSelectionReason, - trackSelectionData, chunkStartTimeUs, chunkEndTimeUs, chunkIndex, sampleOffsetUs, + trackSelectionData, chunkStartTimeUs, chunkEndTimeUs, chunkIndex, 1, sampleOffsetUs, extractorWrapper, format); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index d328e5ecf2..0125d45525 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -19,6 +19,7 @@ import android.net.Uri; import android.os.Handler; import android.os.SystemClock; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; @@ -32,6 +33,7 @@ import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestP import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; @@ -64,7 +66,7 @@ public final class SsMediaSource implements MediaSource, private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000; private final Uri manifestUri; - private final DataSource.Factory dataSourceFactory; + private final DataSource.Factory manifestDataSourceFactory; private final SsChunkSource.Factory chunkSourceFactory; private final int minLoadableRetryCount; private final long livePresentationDelayMs; @@ -72,15 +74,57 @@ public final class SsMediaSource implements MediaSource, private final SsManifestParser manifestParser; private final ArrayList mediaPeriods; - private MediaSource.Listener sourceListener; + private Listener sourceListener; private DataSource manifestDataSource; private Loader manifestLoader; + private LoaderErrorThrower manifestLoaderErrorThrower; private long manifestLoadStartTimestamp; private SsManifest manifest; private Handler manifestRefreshHandler; + /** + * Constructs an instance to play a given {@link SsManifest}, which must not be live. + * + * @param manifest The manifest. {@link SsManifest#isLive} must be false. + * @param chunkSourceFactory A factory for {@link SsChunkSource} instances. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public SsMediaSource(SsManifest manifest, SsChunkSource.Factory chunkSourceFactory, + Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { + this(manifest, chunkSourceFactory, DEFAULT_MIN_LOADABLE_RETRY_COUNT, + eventHandler, eventListener); + } + + /** + * Constructs an instance to play a given {@link SsManifest}, which must not be live. + * + * @param manifest The manifest. {@link SsManifest#isLive} must be false. + * @param chunkSourceFactory A factory for {@link SsChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public SsMediaSource(SsManifest manifest, SsChunkSource.Factory chunkSourceFactory, + int minLoadableRetryCount, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener) { + this(manifest, null, null, null, chunkSourceFactory, minLoadableRetryCount, + DEFAULT_LIVE_PRESENTATION_DELAY_MS, eventHandler, eventListener); + } + + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be live or + * on-demand. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param chunkSourceFactory A factory for {@link SsChunkSource} instances. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ public SsMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, SsChunkSource.Factory chunkSourceFactory, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { @@ -89,42 +133,97 @@ public final class SsMediaSource implements MediaSource, eventListener); } - public SsMediaSource(Uri manifestUri, DataSource.Factory dataSourceFactory, + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be live or + * on-demand. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param chunkSourceFactory A factory for {@link SsChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the + * default start position should precede the end of the live window. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public SsMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, SsChunkSource.Factory chunkSourceFactory, int minLoadableRetryCount, long livePresentationDelayMs, Handler eventHandler, AdaptiveMediaSourceEventListener eventListener) { - this.manifestUri = Util.toLowerInvariant(manifestUri.getLastPathSegment()).equals("manifest") - ? manifestUri : Uri.withAppendedPath(manifestUri, "Manifest"); - this.dataSourceFactory = dataSourceFactory; + this(manifestUri, manifestDataSourceFactory, new SsManifestParser(), chunkSourceFactory, + minLoadableRetryCount, livePresentationDelayMs, eventHandler, eventListener); + } + + /** + * Constructs an instance to play the manifest at a given {@link Uri}, which may be live or + * on-demand. + * + * @param manifestUri The manifest {@link Uri}. + * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used + * to load (and refresh) the manifest. + * @param manifestParser A parser for loaded manifest data. + * @param chunkSourceFactory A factory for {@link SsChunkSource} instances. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the + * default start position should precede the end of the live window. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + public SsMediaSource(Uri manifestUri, DataSource.Factory manifestDataSourceFactory, + SsManifestParser manifestParser, SsChunkSource.Factory chunkSourceFactory, + int minLoadableRetryCount, long livePresentationDelayMs, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener) { + this(null, manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, + minLoadableRetryCount, livePresentationDelayMs, eventHandler, eventListener); + } + + private SsMediaSource(SsManifest manifest, Uri manifestUri, + DataSource.Factory manifestDataSourceFactory, SsManifestParser manifestParser, + SsChunkSource.Factory chunkSourceFactory, int minLoadableRetryCount, + long livePresentationDelayMs, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener) { + Assertions.checkState(manifest == null || !manifest.isLive); + this.manifest = manifest; + this.manifestUri = manifestUri == null ? null + : Util.toLowerInvariant(manifestUri.getLastPathSegment()).equals("manifest") ? manifestUri + : Uri.withAppendedPath(manifestUri, "Manifest"); + this.manifestDataSourceFactory = manifestDataSourceFactory; + this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; this.minLoadableRetryCount = minLoadableRetryCount; this.livePresentationDelayMs = livePresentationDelayMs; this.eventDispatcher = new EventDispatcher(eventHandler, eventListener); - manifestParser = new SsManifestParser(); mediaPeriods = new ArrayList<>(); } // MediaSource implementation. @Override - public void prepareSource(MediaSource.Listener listener) { + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { sourceListener = listener; - manifestDataSource = dataSourceFactory.createDataSource(); - manifestLoader = new Loader("Loader:Manifest"); - manifestRefreshHandler = new Handler(); - startLoadingManifest(); + if (manifest != null) { + manifestLoaderErrorThrower = new LoaderErrorThrower.Dummy(); + processManifest(); + } else { + manifestDataSource = manifestDataSourceFactory.createDataSource(); + manifestLoader = new Loader("Loader:Manifest"); + manifestLoaderErrorThrower = manifestLoader; + manifestRefreshHandler = new Handler(); + startLoadingManifest(); + } } @Override public void maybeThrowSourceInfoRefreshError() throws IOException { - manifestLoader.maybeThrowError(); + manifestLoaderErrorThrower.maybeThrowError(); } @Override public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { Assertions.checkArgument(index == 0); SsMediaPeriod period = new SsMediaPeriod(manifest, chunkSourceFactory, minLoadableRetryCount, - eventDispatcher, manifestLoader, allocator); + eventDispatcher, manifestLoaderErrorThrower, allocator); mediaPeriods.add(period); return period; } @@ -160,6 +259,29 @@ public final class SsMediaSource implements MediaSource, loadDurationMs, loadable.bytesLoaded()); manifest = loadable.getResult(); manifestLoadStartTimestamp = elapsedRealtimeMs - loadDurationMs; + processManifest(); + scheduleManifestRefresh(); + } + + @Override + public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, + loadDurationMs, loadable.bytesLoaded()); + } + + @Override + public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, IOException error) { + boolean isFatal = error instanceof ParserException; + eventDispatcher.loadError(loadable.dataSpec, loadable.type, elapsedRealtimeMs, loadDurationMs, + loadable.bytesLoaded(), error, isFatal); + return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; + } + + // Internal methods + + private void processManifest() { for (int i = 0; i < mediaPeriods.size(); i++) { mediaPeriods.get(i).updateManifest(manifest); } @@ -198,27 +320,8 @@ public final class SsMediaSource implements MediaSource, timeline = new SinglePeriodTimeline(manifest.durationUs, isSeekable); } sourceListener.onSourceInfoRefreshed(timeline, manifest); - scheduleManifestRefresh(); } - @Override - public void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, boolean released) { - eventDispatcher.loadCompleted(loadable.dataSpec, loadable.type, elapsedRealtimeMs, - loadDurationMs, loadable.bytesLoaded()); - } - - @Override - public int onLoadError(ParsingLoadable loadable, long elapsedRealtimeMs, - long loadDurationMs, IOException error) { - boolean isFatal = error instanceof ParserException; - eventDispatcher.loadError(loadable.dataSpec, loadable.type, elapsedRealtimeMs, loadDurationMs, - loadable.bytesLoaded(), error, isFatal); - return isFatal ? Loader.DONT_RETRY_FATAL : Loader.RETRY; - } - - // Internal methods - private void scheduleManifestRefresh() { if (!manifest.isLive) { return; diff --git a/library/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/src/main/java/com/google/android/exoplayer2/text/Cue.java index 93b1dc1d9a..1c29f10c84 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -95,10 +95,22 @@ public class Cue { *

* {@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a line number, where the size of each * line is taken to be the size of the first line of the cue. When {@link #line} is greater than - * or equal to 0, lines count from the start of the viewport (the first line is numbered 0). When - * {@link #line} is negative, lines count from the end of the viewport (the last line is numbered - * -1). For horizontal text the size of the first line of the cue is its height, and the start - * and end of the viewport are the top and bottom respectively. + * or equal to 0 lines count from the start of the viewport, with 0 indicating zero offset from + * the start edge. When {@link #line} is negative lines count from the end of the viewport, with + * -1 indicating zero offset from the end edge. For horizontal text the line spacing is the height + * of the first line of the cue, and the start and end of the viewport are the top and bottom + * respectively. + *

+ * Note that it's particularly important to consider the effect of {@link #lineAnchor} when using + * {@link #LINE_TYPE_NUMBER}. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_START)} positions a + * (potentially multi-line) cue at the very top of the viewport. + * {@code (line == -1 && lineAnchor == ANCHOR_TYPE_END)} positions a (potentially multi-line) cue + * at the very bottom of the viewport. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_END)} + * and {@code (line == -1 && lineAnchor == ANCHOR_TYPE_START)} position cues entirely outside of + * the viewport. {@code (line == 1 && lineAnchor == ANCHOR_TYPE_END)} positions a cue so that only + * the last line is visible at the top of the viewport. + * {@code (line == -2 && lineAnchor == ANCHOR_TYPE_START)} position a cue so that only its first + * line is visible at the bottom of the viewport. */ @LineType public final int lineType; diff --git a/library/src/main/java/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java b/library/src/main/java/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java index 1a69cd7ebd..b2c25631f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java @@ -20,7 +20,7 @@ package com.google.android.exoplayer2.text; */ /* package */ final class SimpleSubtitleOutputBuffer extends SubtitleOutputBuffer { - private SimpleSubtitleDecoder owner; + private final SimpleSubtitleDecoder owner; /** * @param owner The decoder that owns this buffer. diff --git a/library/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/library/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java index 2cbc1ab622..a5d1c0a9c0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java @@ -74,13 +74,21 @@ public interface SubtitleDecoderFactory { if (clazz == null) { throw new IllegalArgumentException("Attempted to create decoder for unsupported format"); } - return clazz.asSubclass(SubtitleDecoder.class).getConstructor().newInstance(); + if (clazz == Cea608Decoder.class) { + return clazz.asSubclass(SubtitleDecoder.class).getConstructor(String.class, Integer.TYPE) + .newInstance(format.sampleMimeType, format.accessibilityChannel); + } else { + return clazz.asSubclass(SubtitleDecoder.class).getConstructor().newInstance(); + } } catch (Exception e) { throw new IllegalStateException("Unexpected error instantiating decoder", e); } } private Class getDecoderClass(String mimeType) { + if (mimeType == null) { + return null; + } try { switch (mimeType) { case MimeTypes.TEXT_VTT: @@ -94,6 +102,7 @@ public interface SubtitleDecoderFactory { case MimeTypes.APPLICATION_TX3G: return Class.forName("com.google.android.exoplayer2.text.tx3g.Tx3gDecoder"); case MimeTypes.APPLICATION_CEA608: + case MimeTypes.APPLICATION_MP4CEA608: return Class.forName("com.google.android.exoplayer2.text.cea.Cea608Decoder"); default: return null; diff --git a/library/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java b/library/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java index e28363f9e4..8dbde1be5e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java @@ -114,19 +114,11 @@ public final class TextRenderer extends BaseRenderer implements Callback { @Override protected void onPositionReset(long positionUs, boolean joining) { + clearOutput(); + resetBuffers(); + decoder.flush(); inputStreamEnded = false; outputStreamEnded = false; - if (subtitle != null) { - subtitle.release(); - subtitle = null; - } - if (nextSubtitle != null) { - nextSubtitle.release(); - nextSubtitle = null; - } - nextInputBuffer = null; - clearOutput(); - decoder.flush(); } @Override @@ -220,18 +212,10 @@ public final class TextRenderer extends BaseRenderer implements Callback { @Override protected void onDisabled() { - if (subtitle != null) { - subtitle.release(); - subtitle = null; - } - if (nextSubtitle != null) { - nextSubtitle.release(); - nextSubtitle = null; - } + clearOutput(); + resetBuffers(); decoder.release(); decoder = null; - nextInputBuffer = null; - clearOutput(); super.onDisabled(); } @@ -247,6 +231,19 @@ public final class TextRenderer extends BaseRenderer implements Callback { return true; } + private void resetBuffers() { + nextInputBuffer = null; + nextSubtitleEventIndex = C.INDEX_UNSET; + if (subtitle != null) { + subtitle.release(); + subtitle = null; + } + if (nextSubtitle != null) { + nextSubtitle.release(); + nextSubtitle = null; + } + } + private long getNextEventTime() { return ((nextSubtitleEventIndex == C.INDEX_UNSET) || (nextSubtitleEventIndex >= subtitle.getEventTimeCount())) ? Long.MAX_VALUE diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 2715b0cbe0..3ae8ded9ba 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -15,20 +15,39 @@ */ package com.google.android.exoplayer2.text.cea; -import android.text.TextUtils; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Layout.Alignment; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; import com.google.android.exoplayer2.text.SubtitleDecoder; import com.google.android.exoplayer2.text.SubtitleInputBuffer; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; /** * A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */ public final class Cea608Decoder extends CeaDecoder { - private static final int NTSC_CC_FIELD_1 = 0x00; private static final int CC_VALID_FLAG = 0x04; + private static final int CC_TYPE_FLAG = 0x02; + private static final int CC_FIELD_FLAG = 0x01; + + private static final int NTSC_CC_FIELD_1 = 0x00; + private static final int NTSC_CC_FIELD_2 = 0x01; + private static final int CC_VALID_608_ID = 0x04; private static final int PAYLOAD_TYPE_CC = 4; private static final int COUNTRY_CODE = 0xB5; @@ -41,9 +60,25 @@ public final class Cea608Decoder extends CeaDecoder { private static final int CC_MODE_POP_ON = 2; private static final int CC_MODE_PAINT_ON = 3; + private static final int[] ROW_INDICES = new int[] {11, 1, 3, 12, 14, 5, 7, 9}; + private static final int[] COLUMN_INDICES = new int[] {0, 4, 8, 12, 16, 20, 24, 28}; + private static final int[] COLORS = new int[] { + Color.WHITE, + Color.GREEN, + Color.BLUE, + Color.CYAN, + Color.RED, + Color.YELLOW, + Color.MAGENTA, + }; + // The default number of rows to display in roll-up captions mode. private static final int DEFAULT_CAPTIONS_ROW_COUNT = 4; + // An implied first byte for packets that are only 2 bytes long, consisting of marker bits + // (0b11111) + valid bit (0b1) + NTSC field 1 type bits (0b00). + private static final byte CC_IMPLICIT_DATA_HEADER = (byte) 0xFC; + /** * Command initiating pop-on style captioning. Subsequent data should be loaded into a * non-displayed memory and held there until the {@link #CTRL_END_OF_CAPTION} command is received, @@ -81,12 +116,10 @@ public final class Cea608Decoder extends CeaDecoder { private static final byte CTRL_ERASE_DISPLAYED_MEMORY = 0x2C; private static final byte CTRL_CARRIAGE_RETURN = 0x2D; private static final byte CTRL_ERASE_NON_DISPLAYED_MEMORY = 0x2E; + private static final byte CTRL_DELETE_TO_END_OF_ROW = 0x24; private static final byte CTRL_BACKSPACE = 0x21; - private static final byte CTRL_MISC_CHAN_1 = 0x14; - private static final byte CTRL_MISC_CHAN_2 = 0x1C; - // 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, // ! " # $ % & ' @@ -156,26 +189,40 @@ public final class Cea608Decoder extends CeaDecoder { }; private final ParsableByteArray ccData; + private final int packetLength; + private final int selectedField; + private final LinkedList cueBuilders; - private final StringBuilder captionStringBuilder; + private CueBuilder currentCueBuilder; + private List cues; + private List lastCues; private int captionMode; private int captionRowCount; - private String captionString; - - private String lastCaptionString; private boolean repeatableControlSet; private byte repeatableControlCc1; private byte repeatableControlCc2; - public Cea608Decoder() { + public Cea608Decoder(String mimeType, int accessibilityChannel) { ccData = new ParsableByteArray(); - - captionStringBuilder = new StringBuilder(); + cueBuilders = new LinkedList<>(); + currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT); + packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3; + switch (accessibilityChannel) { + case 3: + case 4: + selectedField = 2; + break; + case 1: + case 2: + case Format.NO_VALUE: + default: + selectedField = 1; + } setCaptionMode(CC_MODE_UNKNOWN); - captionRowCount = DEFAULT_CAPTIONS_ROW_COUNT; + resetCueBuilders(); } @Override @@ -186,11 +233,11 @@ public final class Cea608Decoder extends CeaDecoder { @Override public void flush() { super.flush(); + cues = null; + lastCues = null; setCaptionMode(CC_MODE_UNKNOWN); + resetCueBuilders(); captionRowCount = DEFAULT_CAPTIONS_ROW_COUNT; - captionStringBuilder.setLength(0); - captionString = null; - lastCaptionString = null; repeatableControlSet = false; repeatableControlCc1 = 0; repeatableControlCc2 = 0; @@ -203,13 +250,13 @@ public final class Cea608Decoder extends CeaDecoder { @Override protected boolean isNewSubtitleDataAvailable() { - return !TextUtils.equals(captionString, lastCaptionString); + return cues != lastCues; } @Override protected Subtitle createSubtitle() { - lastCaptionString = captionString; - return new CeaSubtitle(new Cue(captionString)); + lastCues = cues; + return new CeaSubtitle(cues); } @Override @@ -217,14 +264,23 @@ public final class Cea608Decoder extends CeaDecoder { ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit()); boolean captionDataProcessed = false; boolean isRepeatableControl = false; - while (ccData.bytesLeft() > 0) { - byte ccTypeAndValid = (byte) (ccData.readUnsignedByte() & 0x07); - byte ccData1 = (byte) (ccData.readUnsignedByte() & 0x7F); - byte ccData2 = (byte) (ccData.readUnsignedByte() & 0x7F); + while (ccData.bytesLeft() >= packetLength) { + byte ccDataHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER + : (byte) ccData.readUnsignedByte(); + byte ccData1 = (byte) (ccData.readUnsignedByte() & 0x7F); // strip the parity bit + byte ccData2 = (byte) (ccData.readUnsignedByte() & 0x7F); // strip the parity bit - // Only examine valid NTSC_CC_FIELD_1 packets - if (ccTypeAndValid != (CC_VALID_FLAG | NTSC_CC_FIELD_1)) { - // TODO: Add support for NTSC_CC_FIELD_2 packets + // Only examine valid CEA-608 packets + // TODO: We're currently ignoring the top 5 marker bits, which should all be 1s according + // to the CEA-608 specification. We need to determine if the data should be handled + // differently when that is not the case. + if ((ccDataHeader & (CC_VALID_FLAG | CC_TYPE_FLAG)) != CC_VALID_608_ID) { + continue; + } + + // Only examine packets within the selected field + if ((selectedField == 1 && (ccDataHeader & CC_FIELD_FLAG) != NTSC_CC_FIELD_1) + || (selectedField == 2 && (ccDataHeader & CC_FIELD_FLAG) != NTSC_CC_FIELD_2)) { continue; } @@ -232,49 +288,47 @@ public final class Cea608Decoder extends CeaDecoder { if (ccData1 == 0 && ccData2 == 0) { continue; } + // If we've reached this point then there is data to process; flag that work has been done. captionDataProcessed = true; // Special North American character set. - // ccData1 - P|0|0|1|C|0|0|1 - // ccData2 - P|0|1|1|X|X|X|X - if ((ccData1 == 0x11 || ccData1 == 0x19) && ((ccData2 & 0x70) == 0x30)) { - // TODO: Make use of the channel bit - captionStringBuilder.append(getSpecialChar(ccData2)); + // ccData1 - 0|0|0|1|C|0|0|1 + // ccData2 - 0|0|1|1|X|X|X|X + if (((ccData1 & 0xF7) == 0x11) && ((ccData2 & 0xF0) == 0x30)) { + // TODO: Make use of the channel toggle + currentCueBuilder.append(getSpecialChar(ccData2)); continue; } // Extended Western European character set. - // ccData1 - P|0|0|1|C|0|1|S - // ccData2 - P|0|1|X|X|X|X|X - if ((ccData2 & 0x60) == 0x20) { - // Extended Spanish/Miscellaneous and French character set (S = 0). - if (ccData1 == 0x12 || ccData1 == 0x1A) { - // TODO: Make use of the channel bit - backspace(); // Remove standard equivalent of the special extended char. - captionStringBuilder.append(getExtendedEsFrChar(ccData2)); - continue; - } - - // Extended Portuguese and German/Danish character set (S = 1). - if (ccData1 == 0x13 || ccData1 == 0x1B) { - // TODO: Make use of the channel bit - backspace(); // Remove standard equivalent of the special extended char. - captionStringBuilder.append(getExtendedPtDeChar(ccData2)); - continue; + // ccData1 - 0|0|0|1|C|0|1|S + // ccData2 - 0|0|1|X|X|X|X|X + if (((ccData1 & 0xF6) == 0x12) && (ccData2 & 0xE0) == 0x20) { + // TODO: Make use of the channel toggle + // Remove standard equivalent of the special extended char before appending new one + currentCueBuilder.backspace(); + if ((ccData1 & 0x01) == 0x00) { + // Extended Spanish/Miscellaneous and French character set (S = 0). + currentCueBuilder.append(getExtendedEsFrChar(ccData2)); + } else { + // Extended Portuguese and German/Danish character set (S = 1). + currentCueBuilder.append(getExtendedPtDeChar(ccData2)); } + continue; } // Control character. - if (ccData1 < 0x20) { + // ccData1 - 0|0|0|X|X|X|X|X + if ((ccData1 & 0xE0) == 0x00) { isRepeatableControl = handleCtrl(ccData1, ccData2); continue; } // Basic North American character set. - captionStringBuilder.append(getChar(ccData1)); - if (ccData2 >= 0x20) { - captionStringBuilder.append(getChar(ccData2)); + currentCueBuilder.append(getChar(ccData1)); + if ((ccData2 & 0xE0) != 0x00) { + currentCueBuilder.append(getChar(ccData2)); } } @@ -283,34 +337,106 @@ public final class Cea608Decoder extends CeaDecoder { repeatableControlSet = false; } if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { - captionString = getDisplayCaption(); + cues = getDisplayCues(); } } } private boolean handleCtrl(byte cc1, byte cc2) { boolean isRepeatableControl = isRepeatable(cc1); + + // Most control commands are sent twice in succession to ensure they are received properly. + // We don't want to process duplicate commands, so if we see the same repeatable command twice + // in a row, ignore the second one. if (isRepeatableControl) { if (repeatableControlSet && repeatableControlCc1 == cc1 && repeatableControlCc2 == cc2) { + // This is a duplicate. Clear the repeatable control flag and return. repeatableControlSet = false; return true; } else { + // This is a repeatable command, but we haven't see it yet, so set the repeabable control + // flag (to ensure we ignore the next one should it be a duplicate) and continue processing + // the command. repeatableControlSet = true; repeatableControlCc1 = cc1; repeatableControlCc2 = cc2; } } - if (isMiscCode(cc1, cc2)) { - handleMiscCode(cc2); + + if (isMidrowCtrlCode(cc1, cc2)) { + handleMidrowCtrl(cc2); } else if (isPreambleAddressCode(cc1, cc2)) { - // TODO: Add better handling of this with specific positioning. - maybeAppendNewline(); + handlePreambleAddressCode(cc1, cc2); + } else if (isTabCtrlCode(cc1, cc2)) { + currentCueBuilder.tab(cc2 - 0x20); + } else if (isMiscCode(cc1, cc2)) { + handleMiscCode(cc2); } + return isRepeatableControl; } + private void handleMidrowCtrl(byte cc2) { + // TODO: support the extended styles (i.e. backgrounds and transparencies) + + // cc2 - 0|0|1|0|ATRBT|U + // ATRBT is the 3-byte encoded attribute, and U is the underline toggle + boolean isUnderlined = (cc2 & 0x01) == 0x01; + currentCueBuilder.setUnderline(isUnderlined); + + int attribute = (cc2 >> 1) & 0x0F; + if (attribute == 0x07) { + currentCueBuilder.setMidrowStyle(new StyleSpan(Typeface.ITALIC), 2); + currentCueBuilder.setMidrowStyle(new ForegroundColorSpan(Color.WHITE), 1); + } else { + currentCueBuilder.setMidrowStyle(new ForegroundColorSpan(COLORS[attribute]), 1); + } + } + + private void handlePreambleAddressCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|E|ROW + // C is the channel toggle, E is the extended flag, and ROW is the encoded row + int row = ROW_INDICES[cc1 & 0x07]; + // TODO: Make use of the channel toggle + // TODO: support the extended address and style + + // cc2 - 0|1|N|ATTRBTE|U + // N is the next row down toggle, ATTRBTE is the 4-byte encoded attribute, and U is the + // underline toggle. + boolean nextRowDown = (cc2 & 0x20) != 0; + if (nextRowDown) { + row++; + } + + if (row != currentCueBuilder.getRow()) { + if (captionMode != CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { + currentCueBuilder = new CueBuilder(captionMode, captionRowCount); + cueBuilders.add(currentCueBuilder); + } + currentCueBuilder.setRow(row); + } + + if ((cc2 & 0x01) == 0x01) { + currentCueBuilder.setPreambleStyle(new UnderlineSpan()); + } + + // cc2 - 0|1|N|0|STYLE|U + // cc2 - 0|1|N|1|CURSR|U + int attribute = cc2 >> 1 & 0x0F; + if (attribute <= 0x07) { + if (attribute == 0x07) { + currentCueBuilder.setPreambleStyle(new StyleSpan(Typeface.ITALIC)); + currentCueBuilder.setPreambleStyle(new ForegroundColorSpan(Color.WHITE)); + } else { + currentCueBuilder.setPreambleStyle(new ForegroundColorSpan(COLORS[attribute])); + } + } else { + currentCueBuilder.setIndent(COLUMN_INDICES[attribute & 0x07]); + } + } + private void handleMiscCode(byte cc2) { switch (cc2) { case CTRL_ROLL_UP_CAPTIONS_2_ROWS: @@ -339,68 +465,43 @@ public final class Cea608Decoder extends CeaDecoder { switch (cc2) { case CTRL_ERASE_DISPLAYED_MEMORY: - captionString = null; + cues = null; if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { - captionStringBuilder.setLength(0); + resetCueBuilders(); } - return; + break; case CTRL_ERASE_NON_DISPLAYED_MEMORY: - captionStringBuilder.setLength(0); - return; + resetCueBuilders(); + break; case CTRL_END_OF_CAPTION: - captionString = getDisplayCaption(); - captionStringBuilder.setLength(0); - return; + cues = getDisplayCues(); + resetCueBuilders(); + break; case CTRL_CARRIAGE_RETURN: - maybeAppendNewline(); - return; - case CTRL_BACKSPACE: - if (captionStringBuilder.length() > 0) { - captionStringBuilder.setLength(captionStringBuilder.length() - 1); + // carriage returns only apply to rollup captions; don't bother if we don't have anything + // to add a carriage return to + if (captionMode == CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { + currentCueBuilder.rollUp(); } - return; + break; + case CTRL_BACKSPACE: + currentCueBuilder.backspace(); + break; + case CTRL_DELETE_TO_END_OF_ROW: + // TODO: implement + break; } } - private void backspace() { - if (captionStringBuilder.length() > 0) { - captionStringBuilder.setLength(captionStringBuilder.length() - 1); + private List getDisplayCues() { + List displayCues = new ArrayList<>(); + for (int i = 0; i < cueBuilders.size(); i++) { + Cue cue = cueBuilders.get(i).build(); + if (cue != null) { + displayCues.add(cue); + } } - } - - private void maybeAppendNewline() { - int buildLength = captionStringBuilder.length(); - if (buildLength > 0 && captionStringBuilder.charAt(buildLength - 1) != '\n') { - captionStringBuilder.append('\n'); - } - } - - private String getDisplayCaption() { - int buildLength = captionStringBuilder.length(); - if (buildLength == 0) { - return null; - } - - boolean endsWithNewline = captionStringBuilder.charAt(buildLength - 1) == '\n'; - if (buildLength == 1 && endsWithNewline) { - return null; - } - - int endIndex = endsWithNewline ? buildLength - 1 : buildLength; - if (captionMode != CC_MODE_ROLL_UP) { - return captionStringBuilder.substring(0, endIndex); - } - - int startIndex = 0; - int searchBackwardFromIndex = endIndex; - for (int i = 0; i < captionRowCount && searchBackwardFromIndex != -1; i++) { - searchBackwardFromIndex = captionStringBuilder.lastIndexOf("\n", searchBackwardFromIndex - 1); - } - if (searchBackwardFromIndex != -1) { - startIndex = searchBackwardFromIndex + 1; - } - captionStringBuilder.delete(0, startIndex); - return captionStringBuilder.substring(0, endIndex - startIndex); + return displayCues; } private void setCaptionMode(int captionMode) { @@ -410,20 +511,26 @@ public final class Cea608Decoder extends CeaDecoder { this.captionMode = captionMode; // Clear the working memory. - captionStringBuilder.setLength(0); + resetCueBuilders(); if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_UNKNOWN) { // When switching to roll-up or unknown, we also need to clear the caption. - captionString = null; + cues = null; } } + private void resetCueBuilders() { + currentCueBuilder.reset(captionMode, captionRowCount); + cueBuilders.clear(); + cueBuilders.add(currentCueBuilder); + } + 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; + int index = ccData & 0x0F; return (char) SPECIAL_CHARACTER_SET[index]; } @@ -437,17 +544,33 @@ public final class Cea608Decoder extends CeaDecoder { return (char) SPECIAL_PT_DE_CHARACTER_SET[index]; } - private static boolean isMiscCode(byte cc1, byte cc2) { - return (cc1 == CTRL_MISC_CHAN_1 || cc1 == CTRL_MISC_CHAN_2) - && (cc2 >= 0x20 && cc2 <= 0x2F); + private static boolean isMidrowCtrlCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|0|0|1 + // cc2 - 0|0|1|0|X|X|X|X + return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x20); } private static boolean isPreambleAddressCode(byte cc1, byte cc2) { - return (cc1 >= 0x10 && cc1 <= 0x1F) && (cc2 >= 0x40 && cc2 <= 0x7F); + // cc1 - 0|0|0|1|C|X|X|X + // cc2 - 0|1|X|X|X|X|X|X + return ((cc1 & 0xF0) == 0x10) && ((cc2 & 0xC0) == 0x40); + } + + private static boolean isTabCtrlCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|1|1|1 + // cc2 - 0|0|1|0|0|0|0|1 to 0|0|1|0|0|0|1|1 + return ((cc1 & 0xF7) == 0x17) && (cc2 >= 0x21 && cc2 <= 0x23); + } + + private static boolean isMiscCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|1|0|0 + // cc2 - 0|0|1|0|X|X|X|X + return ((cc1 & 0xF7) == 0x14) && ((cc2 & 0xF0) == 0x20); } private static boolean isRepeatable(byte cc1) { - return cc1 >= 0x10 && cc1 <= 0x1F; + // cc1 - 0|0|0|1|X|X|X|X + return (cc1 & 0xF0) == 0x10; } /** @@ -475,4 +598,219 @@ public final class Cea608Decoder extends CeaDecoder { && userIdentifier == USER_ID && userDataTypeCode == USER_DATA_TYPE_CODE; } + private static class CueBuilder { + + private static final int POSITION_UNSET = -1; + + // 608 captions define a 15 row by 32 column screen grid. These constants convert from 608 + // positions to normalized screen position. + private static final int SCREEN_CHARWIDTH = 32; + private static final int BASE_ROW = 15; + + private final List preambleStyles; + private final List midrowStyles; + private final List rolledUpCaptions; + private final SpannableStringBuilder captionStringBuilder; + + private int row; + private int indent; + private int tabOffset; + private int captionMode; + private int captionRowCount; + private int underlineStartPosition; + + public CueBuilder(int captionMode, int captionRowCount) { + preambleStyles = new ArrayList<>(); + midrowStyles = new ArrayList<>(); + rolledUpCaptions = new LinkedList<>(); + captionStringBuilder = new SpannableStringBuilder(); + reset(captionMode, captionRowCount); + } + + public void reset(int captionMode, int captionRowCount) { + preambleStyles.clear(); + midrowStyles.clear(); + rolledUpCaptions.clear(); + captionStringBuilder.clear(); + row = BASE_ROW; + indent = 0; + tabOffset = 0; + this.captionMode = captionMode; + this.captionRowCount = captionRowCount; + underlineStartPosition = POSITION_UNSET; + } + + public boolean isEmpty() { + return preambleStyles.isEmpty() && midrowStyles.isEmpty() && rolledUpCaptions.isEmpty() + && captionStringBuilder.length() == 0; + } + + public void backspace() { + int length = captionStringBuilder.length(); + if (length > 0) { + captionStringBuilder.delete(length - 1, length); + } + } + + public int getRow() { + return row; + } + + public void setRow(int row) { + this.row = row; + } + + public void rollUp() { + rolledUpCaptions.add(buildSpannableString()); + captionStringBuilder.clear(); + preambleStyles.clear(); + midrowStyles.clear(); + underlineStartPosition = POSITION_UNSET; + + int numRows = Math.min(captionRowCount, row); + while (rolledUpCaptions.size() >= numRows) { + rolledUpCaptions.remove(0); + } + } + + public void setIndent(int indent) { + this.indent = indent; + } + + public void tab(int tabs) { + tabOffset += tabs; + } + + public void setPreambleStyle(CharacterStyle style) { + preambleStyles.add(style); + } + + public void setMidrowStyle(CharacterStyle style, int nextStyleIncrement) { + midrowStyles.add(new CueStyle(style, captionStringBuilder.length(), nextStyleIncrement)); + } + + public void setUnderline(boolean enabled) { + if (enabled) { + underlineStartPosition = captionStringBuilder.length(); + } else if (underlineStartPosition != POSITION_UNSET) { + // underline spans won't overlap, so it's safe to modify the builder directly with them + captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + underlineStartPosition = POSITION_UNSET; + } + } + + public void append(char text) { + captionStringBuilder.append(text); + } + + public SpannableString buildSpannableString() { + int length = captionStringBuilder.length(); + + // preamble styles apply to the entire cue + for (int i = 0; i < preambleStyles.size(); i++) { + captionStringBuilder.setSpan(preambleStyles.get(i), 0, length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // midrow styles only apply to part of the cue, and after preamble styles + for (int i = 0; i < midrowStyles.size(); i++) { + CueStyle cueStyle = midrowStyles.get(i); + int end = (i < midrowStyles.size() - cueStyle.nextStyleIncrement) + ? midrowStyles.get(i + cueStyle.nextStyleIncrement).start + : length; + captionStringBuilder.setSpan(cueStyle.style, cueStyle.start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // special case for midrow underlines that went to the end of the cue + if (underlineStartPosition != POSITION_UNSET) { + captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + return new SpannableString(captionStringBuilder); + } + + public Cue build() { + SpannableStringBuilder cueString = new SpannableStringBuilder(); + // Add any rolled up captions, separated by new lines. + for (int i = 0; i < rolledUpCaptions.size(); i++) { + cueString.append(rolledUpCaptions.get(i)); + cueString.append('\n'); + } + // Add the current line. + cueString.append(buildSpannableString()); + + if (cueString.length() == 0) { + // The cue is empty. + return null; + } + + float position; + int positionAnchor; + // The number of empty columns before the start of the text, in the range [0-31]. + int startPadding = indent + tabOffset; + // The number of empty columns after the end of the text, in the same range. + int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); + int startEndPaddingDelta = startPadding - endPadding; + if (captionMode == CC_MODE_POP_ON && Math.abs(startEndPaddingDelta) < 3) { + // Treat approximately centered pop-on captions are middle aligned. + position = 0.5f; + positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) { + // Treat pop-on captions with less padding at the end than the start as end aligned. + position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + positionAnchor = Cue.ANCHOR_TYPE_END; + } else { + // For all other cases assume start aligned. + position = (float) startPadding / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + positionAnchor = Cue.ANCHOR_TYPE_START; + } + + int lineAnchor; + int line; + // Note: Row indices are in the range [1-15]. + if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { + lineAnchor = Cue.ANCHOR_TYPE_END; + line = row - BASE_ROW; + // Two line adjustments. The first is because line indices from the bottom of the window + // start from -1 rather than 0. The second is a blank row to act as the safe area. + line -= 2; + } else { + lineAnchor = Cue.ANCHOR_TYPE_START; + // Line indices from the top of the window start from 0, but we want a blank row to act as + // the safe area. As a result no adjustment is necessary. + line = row; + } + + return new Cue(cueString, Alignment.ALIGN_NORMAL, line, Cue.LINE_TYPE_NUMBER, lineAnchor, + position, positionAnchor, Cue.DIMEN_UNSET); + } + + @Override + public String toString() { + return captionStringBuilder.toString(); + } + + private static class CueStyle { + + public final CharacterStyle style; + public final int start; + public final int nextStyleIncrement; + + public CueStyle(CharacterStyle style, int start, int nextStyleIncrement) { + this.style = style; + this.start = start; + this.nextStyleIncrement = nextStyleIncrement; + } + + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/CeaSubtitle.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/CeaSubtitle.java index 5becefe106..620b2c7d80 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/cea/CeaSubtitle.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/CeaSubtitle.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.text.cea; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; -import java.util.Collections; import java.util.List; /** @@ -28,14 +27,10 @@ import java.util.List; private final List cues; /** - * @param cue The subtitle cue. + * @param cues The subtitle cues. */ - public CeaSubtitle(Cue cue) { - if (cue == null) { - cues = Collections.emptyList(); - } else { - cues = Collections.singletonList(cue); - } + public CeaSubtitle(List cues) { + this.cues = cues; } @Override @@ -56,7 +51,6 @@ import java.util.List; @Override public List getCues(long timeUs) { return cues; - } } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/library/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java index 43a93353c3..a848022ba9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java @@ -34,9 +34,9 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { private static final String TAG = "SubripDecoder"; - private static final Pattern SUBRIP_TIMING_LINE = Pattern.compile("(\\S*)\\s*-->\\s*(\\S*)"); - private static final Pattern SUBRIP_TIMESTAMP = - Pattern.compile("(?:(\\d+):)?(\\d+):(\\d+),(\\d+)"); + private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+),(\\d+)"; + private static final Pattern SUBRIP_TIMING_LINE = + Pattern.compile("\\s*(" + SUBRIP_TIMECODE + ")\\s*-->\\s*(" + SUBRIP_TIMECODE + ")?\\s*"); private final StringBuilder textBuilder; @@ -50,7 +50,6 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { ArrayList cues = new ArrayList<>(); LongArray cueTimesUs = new LongArray(); ParsableByteArray subripData = new ParsableByteArray(bytes, length); - boolean haveEndTimecode; String currentLine; while ((currentLine = subripData.readLine()) != null) { @@ -68,15 +67,14 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { } // Read and parse the timing line. - haveEndTimecode = false; + boolean haveEndTimecode = false; currentLine = subripData.readLine(); Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine); - if (matcher.find()) { - cueTimesUs.add(parseTimecode(matcher.group(1))); - String endTimecode = matcher.group(2); - if (!TextUtils.isEmpty(endTimecode)) { + if (matcher.matches()) { + cueTimesUs.add(parseTimecode(matcher, 1)); + if (!TextUtils.isEmpty(matcher.group(6))) { haveEndTimecode = true; - cueTimesUs.add(parseTimecode(matcher.group(2))); + cueTimesUs.add(parseTimecode(matcher, 6)); } } else { Log.w(TAG, "Skipping invalid timing: " + currentLine); @@ -105,15 +103,11 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { return new SubripSubtitle(cuesArray, cueTimesUsArray); } - private static long parseTimecode(String s) throws NumberFormatException { - Matcher matcher = SUBRIP_TIMESTAMP.matcher(s); - if (!matcher.matches()) { - throw new NumberFormatException("has invalid format"); - } - long timestampMs = Long.parseLong(matcher.group(1)) * 60 * 60 * 1000; - timestampMs += Long.parseLong(matcher.group(2)) * 60 * 1000; - timestampMs += Long.parseLong(matcher.group(3)) * 1000; - timestampMs += Long.parseLong(matcher.group(4)); + private static long parseTimecode(Matcher matcher, int groupOffset) { + long timestampMs = Long.parseLong(matcher.group(groupOffset + 1)) * 60 * 60 * 1000; + timestampMs += Long.parseLong(matcher.group(groupOffset + 2)) * 60 * 1000; + timestampMs += Long.parseLong(matcher.group(groupOffset + 3)) * 1000; + timestampMs += Long.parseLong(matcher.group(groupOffset + 4)); return timestampMs * 1000; } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/library/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java index bb89b05603..21333081c6 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -92,21 +92,22 @@ import java.util.Map; builder.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - if (style.getFontSizeUnit() != TtmlStyle.UNSPECIFIED) { - switch (style.getFontSizeUnit()) { - case TtmlStyle.FONT_SIZE_UNIT_PIXEL: - builder.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - case TtmlStyle.FONT_SIZE_UNIT_EM: - builder.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - case TtmlStyle.FONT_SIZE_UNIT_PERCENT: - builder.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - } + switch (style.getFontSizeUnit()) { + case TtmlStyle.FONT_SIZE_UNIT_PIXEL: + builder.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.FONT_SIZE_UNIT_EM: + builder.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.FONT_SIZE_UNIT_PERCENT: + builder.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.UNSPECIFIED: + // Do nothing. + break; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index b7345e0b5f..932d4a6bed 100644 --- a/library/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -256,7 +256,13 @@ import java.util.regex.Pattern; if (s.endsWith("%")) { builder.setLine(WebvttParserUtil.parsePercentage(s)).setLineType(Cue.LINE_TYPE_FRACTION); } else { - builder.setLine(Integer.parseInt(s)).setLineType(Cue.LINE_TYPE_NUMBER); + int lineNumber = Integer.parseInt(s); + if (lineNumber < 0) { + // WebVTT defines line -1 as last visible row when lineAnchor is ANCHOR_TYPE_START, where-as + // Cue defines it to be the first row that's not visible. + lineNumber--; + } + builder.setLine(lineNumber).setLineType(Cue.LINE_TYPE_NUMBER); } } @@ -413,21 +419,22 @@ import java.util.regex.Pattern; spannedText.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - if (style.getFontSizeUnit() != WebvttCssStyle.UNSPECIFIED) { - switch (style.getFontSizeUnit()) { - case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL: - spannedText.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - case WebvttCssStyle.FONT_SIZE_UNIT_EM: - spannedText.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT: - spannedText.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - break; - } + switch (style.getFontSizeUnit()) { + case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL: + spannedText.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_EM: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.UNSPECIFIED: + // Do nothing. + break; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 81d79ac055..79979401f7 100644 --- a/library/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.trackselection; import android.content.Context; import android.graphics.Point; -import android.os.Handler; import android.text.TextUtils; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -56,6 +55,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { public final int maxVideoWidth; public final int maxVideoHeight; public final boolean exceedVideoConstraintsIfNecessary; + public final boolean exceedRendererCapabilitiesIfNecessary; public final int viewportWidth; public final int viewportHeight; public final boolean orientationMayChange; @@ -68,13 +68,14 @@ public class DefaultTrackSelector extends MappingTrackSelector { *

  • Adaptation between different mime types is not allowed.
  • *
  • Non seamless adaptation is allowed.
  • *
  • No max limit for video width/height.
  • - *
  • Video constraints are ignored if no supported selection can be made otherwise.
  • + *
  • Video constraints are exceeded if no supported selection can be made otherwise.
  • + *
  • Renderer capabilities are exceeded if no supported selection can be made.
  • *
  • No viewport width/height constraints are set.
  • * */ public Parameters() { - this(null, null, false, true, Integer.MAX_VALUE, Integer.MAX_VALUE, true, Integer.MAX_VALUE, - Integer.MAX_VALUE, true); + this(null, null, false, true, Integer.MAX_VALUE, Integer.MAX_VALUE, true, true, + Integer.MAX_VALUE, Integer.MAX_VALUE, true); } /** @@ -87,8 +88,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { * @param allowNonSeamlessAdaptiveness Whether non-seamless adaptation is allowed. * @param maxVideoWidth Maximum allowed video width. * @param maxVideoHeight Maximum allowed video height. - * @param exceedVideoConstraintsIfNecessary True to ignore video constraints when no selections - * can be made otherwise. False to force constraints anyway. + * @param exceedVideoConstraintsIfNecessary Whether to exceed video constraints when no + * selection can be made otherwise. + * @param exceedRendererCapabilitiesIfNecessary Whether to exceed renderer capabilities when no + * selection can be made otherwise. * @param viewportWidth Viewport width in pixels. * @param viewportHeight Viewport height in pixels. * @param orientationMayChange Whether orientation may change during playback. @@ -96,7 +99,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { public Parameters(String preferredAudioLanguage, String preferredTextLanguage, boolean allowMixedMimeAdaptiveness, boolean allowNonSeamlessAdaptiveness, int maxVideoWidth, int maxVideoHeight, boolean exceedVideoConstraintsIfNecessary, - int viewportWidth, int viewportHeight, boolean orientationMayChange) { + boolean exceedRendererCapabilitiesIfNecessary, int viewportWidth, int viewportHeight, + boolean orientationMayChange) { this.preferredAudioLanguage = preferredAudioLanguage; this.preferredTextLanguage = preferredTextLanguage; this.allowMixedMimeAdaptiveness = allowMixedMimeAdaptiveness; @@ -104,6 +108,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.maxVideoWidth = maxVideoWidth; this.maxVideoHeight = maxVideoHeight; this.exceedVideoConstraintsIfNecessary = exceedVideoConstraintsIfNecessary; + this.exceedRendererCapabilitiesIfNecessary = exceedRendererCapabilitiesIfNecessary; this.viewportWidth = viewportWidth; this.viewportHeight = viewportHeight; this.orientationMayChange = orientationMayChange; @@ -125,7 +130,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { } return new Parameters(preferredAudioLanguage, preferredTextLanguage, allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, - exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, orientationMayChange); + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -141,9 +147,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -157,9 +163,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -173,9 +179,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -190,9 +196,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -217,8 +223,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * Returns a {@link Parameters} instance with the provided * {@code exceedVideoConstraintsIfNecessary} value. * - * @param exceedVideoConstraintsIfNecessary True to ignore video constraints when no selections - * can be made otherwise. False to force constraints anyway. + * @param exceedVideoConstraintsIfNecessary Whether to exceed video constraints when no + * selection can be made otherwise. * @return A {@link Parameters} instance with the provided * {@code exceedVideoConstraintsIfNecessary} value. */ @@ -228,9 +234,29 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); + } + + /** + * Returns a {@link Parameters} instance with the provided + * {@code exceedRendererCapabilitiesIfNecessary} value. + * + * @param exceedRendererCapabilitiesIfNecessary Whether to exceed renderer capabilities when no + * selection can be made otherwise. + * @return A {@link Parameters} instance with the provided + * {@code exceedRendererCapabilitiesIfNecessary} value. + */ + public Parameters withExceedRendererCapabilitiesIfNecessary( + boolean exceedRendererCapabilitiesIfNecessary) { + if (exceedRendererCapabilitiesIfNecessary == this.exceedRendererCapabilitiesIfNecessary) { + return this; + } + return new Parameters(preferredAudioLanguage, preferredTextLanguage, + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -248,9 +274,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } return new Parameters(preferredAudioLanguage, preferredTextLanguage, - allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, - maxVideoHeight, exceedVideoConstraintsIfNecessary, viewportWidth, viewportHeight, - orientationMayChange); + allowMixedMimeAdaptiveness, allowNonSeamlessAdaptiveness, maxVideoWidth, maxVideoHeight, + exceedVideoConstraintsIfNecessary, exceedRendererCapabilitiesIfNecessary, viewportWidth, + viewportHeight, orientationMayChange); } /** @@ -290,6 +316,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { && allowNonSeamlessAdaptiveness == other.allowNonSeamlessAdaptiveness && maxVideoWidth == other.maxVideoWidth && maxVideoHeight == other.maxVideoHeight && exceedVideoConstraintsIfNecessary == other.exceedVideoConstraintsIfNecessary + && exceedRendererCapabilitiesIfNecessary == other.exceedRendererCapabilitiesIfNecessary && orientationMayChange == other.orientationMayChange && viewportWidth == other.viewportWidth && viewportHeight == other.viewportHeight && TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) @@ -305,6 +332,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { result = 31 * result + maxVideoWidth; result = 31 * result + maxVideoHeight; result = 31 * result + (exceedVideoConstraintsIfNecessary ? 1 : 0); + result = 31 * result + (exceedRendererCapabilitiesIfNecessary ? 1 : 0); result = 31 * result + (orientationMayChange ? 1 : 0); result = 31 * result + viewportWidth; result = 31 * result + viewportHeight; @@ -320,33 +348,27 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ private static final float FRACTION_TO_CONSIDER_FULLSCREEN = 0.98f; private static final int[] NO_TRACKS = new int[0]; + private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; private final TrackSelection.Factory adaptiveVideoTrackSelectionFactory; - private final AtomicReference params; + private final AtomicReference paramsReference; /** * Constructs an instance that does not support adaptive video. - * - * @param eventHandler A handler to use when delivering events to listeners. May be null if - * listeners will not be added. */ - public DefaultTrackSelector(Handler eventHandler) { - this(eventHandler, null); + public DefaultTrackSelector() { + this(null); } /** * Constructs an instance that uses a factory to create adaptive video track selections. * - * @param eventHandler A handler to use when delivering events to listeners. May be null if - * listeners will not be added. * @param adaptiveVideoTrackSelectionFactory A factory for adaptive video {@link TrackSelection}s, * or null if the selector should not support adaptive video. */ - public DefaultTrackSelector(Handler eventHandler, - TrackSelection.Factory adaptiveVideoTrackSelectionFactory) { - super(eventHandler); + public DefaultTrackSelector(TrackSelection.Factory adaptiveVideoTrackSelectionFactory) { this.adaptiveVideoTrackSelectionFactory = adaptiveVideoTrackSelectionFactory; - params = new AtomicReference<>(new Parameters()); + paramsReference = new AtomicReference<>(new Parameters()); } /** @@ -355,8 +377,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * @param params The parameters for track selection. */ public void setParameters(Parameters params) { - if (!this.params.get().equals(params)) { - this.params.set(Assertions.checkNotNull(params)); + Assertions.checkNotNull(params); + if (!paramsReference.getAndSet(params).equals(params)) { invalidate(); } } @@ -367,7 +389,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * @return The current selection parameters. */ public Parameters getParameters() { - return params.get(); + return paramsReference.get(); } // MappingTrackSelector implementation. @@ -378,7 +400,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { throws ExoPlaybackException { // Make a track selection for each renderer. TrackSelection[] rendererTrackSelections = new TrackSelection[rendererCapabilities.length]; - Parameters params = this.params.get(); + Parameters params = paramsReference.get(); for (int i = 0; i < rendererCapabilities.length; i++) { switch (rendererCapabilities[i].getTrackType()) { case C.TRACK_TYPE_VIDEO: @@ -387,20 +409,23 @@ public class DefaultTrackSelector extends MappingTrackSelector { params.maxVideoHeight, params.allowNonSeamlessAdaptiveness, params.allowMixedMimeAdaptiveness, params.viewportWidth, params.viewportHeight, params.orientationMayChange, adaptiveVideoTrackSelectionFactory, - params.exceedVideoConstraintsIfNecessary); + params.exceedVideoConstraintsIfNecessary, + params.exceedRendererCapabilitiesIfNecessary); break; case C.TRACK_TYPE_AUDIO: rendererTrackSelections[i] = selectAudioTrack(rendererTrackGroupArrays[i], - rendererFormatSupports[i], params.preferredAudioLanguage); + rendererFormatSupports[i], params.preferredAudioLanguage, + params.exceedRendererCapabilitiesIfNecessary); break; case C.TRACK_TYPE_TEXT: rendererTrackSelections[i] = selectTextTrack(rendererTrackGroupArrays[i], rendererFormatSupports[i], params.preferredTextLanguage, - params.preferredAudioLanguage); + params.preferredAudioLanguage, params.exceedRendererCapabilitiesIfNecessary); break; default: rendererTrackSelections[i] = selectOtherTrack(rendererCapabilities[i].getTrackType(), - rendererTrackGroupArrays[i], rendererFormatSupports[i]); + rendererTrackGroupArrays[i], rendererFormatSupports[i], + params.exceedRendererCapabilitiesIfNecessary); break; } } @@ -414,7 +439,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { boolean allowNonSeamlessAdaptiveness, boolean allowMixedMimeAdaptiveness, int viewportWidth, int viewportHeight, boolean orientationMayChange, TrackSelection.Factory adaptiveVideoTrackSelectionFactory, - boolean exceedConstraintsIfNecessary) throws ExoPlaybackException { + boolean exceedConstraintsIfNecessary, boolean exceedRendererCapabilitiesIfNecessary) + throws ExoPlaybackException { TrackSelection selection = null; if (adaptiveVideoTrackSelectionFactory != null) { selection = selectAdaptiveVideoTrack(rendererCapabilities, groups, formatSupport, @@ -424,7 +450,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { } if (selection == null) { selection = selectFixedVideoTrack(groups, formatSupport, maxVideoWidth, maxVideoHeight, - viewportWidth, viewportHeight, orientationMayChange, exceedConstraintsIfNecessary); + viewportWidth, viewportHeight, orientationMayChange, exceedConstraintsIfNecessary, + exceedRendererCapabilitiesIfNecessary); } return selection; } @@ -520,7 +547,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { private static boolean isSupportedAdaptiveVideoTrack(Format format, String mimeType, int formatSupport, int requiredAdaptiveSupport, int maxVideoWidth, int maxVideoHeight) { - return isSupported(formatSupport) && ((formatSupport & requiredAdaptiveSupport) != 0) + return isSupported(formatSupport, false) && ((formatSupport & requiredAdaptiveSupport) != 0) && (mimeType == null || Util.areEqual(format.sampleMimeType, mimeType)) && (format.width == Format.NO_VALUE || format.width <= maxVideoWidth) && (format.height == Format.NO_VALUE || format.height <= maxVideoHeight); @@ -528,37 +555,44 @@ public class DefaultTrackSelector extends MappingTrackSelector { private static TrackSelection selectFixedVideoTrack(TrackGroupArray groups, int[][] formatSupport, int maxVideoWidth, int maxVideoHeight, int viewportWidth, - int viewportHeight, boolean orientationMayChange, boolean exceedConstraintsIfNecessary) { + int viewportHeight, boolean orientationMayChange, boolean exceedConstraintsIfNecessary, + boolean exceedRendererCapabilitiesIfNecessary) { TrackGroup selectedGroup = null; int selectedTrackIndex = 0; + int selectedTrackScore = 0; int selectedPixelCount = Format.NO_VALUE; - boolean selectedIsWithinConstraints = false; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { - TrackGroup group = groups.get(groupIndex); - List selectedTrackIndices = getViewportFilteredTrackIndices(group, viewportWidth, - viewportHeight, orientationMayChange); + TrackGroup trackGroup = groups.get(groupIndex); + List selectedTrackIndices = getViewportFilteredTrackIndices(trackGroup, + viewportWidth, viewportHeight, orientationMayChange); int[] trackFormatSupport = formatSupport[groupIndex]; - for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { - if (isSupported(trackFormatSupport[trackIndex])) { - Format format = group.getFormat(trackIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); boolean isWithinConstraints = selectedTrackIndices.contains(trackIndex) && (format.width == Format.NO_VALUE || format.width <= maxVideoWidth) && (format.height == Format.NO_VALUE || format.height <= maxVideoHeight); - int pixelCount = format.getPixelCount(); - boolean selectTrack; - if (selectedIsWithinConstraints) { - selectTrack = isWithinConstraints - && comparePixelCounts(pixelCount, selectedPixelCount) > 0; - } else { - selectTrack = isWithinConstraints || (exceedConstraintsIfNecessary - && (selectedGroup == null - || comparePixelCounts(pixelCount, selectedPixelCount) < 0)); + if (!isWithinConstraints && !exceedConstraintsIfNecessary) { + // Track should not be selected. + continue; + } + int trackScore = isWithinConstraints ? 2 : 1; + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + boolean selectTrack = trackScore > selectedTrackScore; + if (trackScore == selectedTrackScore) { + // Use the pixel count as a tie breaker. If we're within constraints prefer a higher + // pixel count, else prefer a lower count. If still tied then prefer the first track + // (i.e. the one that's already selected). + int pixelComparison = comparePixelCounts(format.getPixelCount(), selectedPixelCount); + selectTrack = isWithinConstraints ? pixelComparison > 0 : pixelComparison < 0; } if (selectTrack) { - selectedGroup = group; + selectedGroup = trackGroup; selectedTrackIndex = trackIndex; - selectedPixelCount = pixelCount; - selectedIsWithinConstraints = isWithinConstraints; + selectedTrackScore = trackScore; + selectedPixelCount = format.getPixelCount(); } } } @@ -585,7 +619,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Audio track selection implementation. protected TrackSelection selectAudioTrack(TrackGroupArray groups, int[][] formatSupport, - String preferredAudioLanguage) { + String preferredAudioLanguage, boolean exceedRendererCapabilitiesIfNecessary) { TrackGroup selectedGroup = null; int selectedTrackIndex = 0; int selectedTrackScore = 0; @@ -593,7 +627,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { TrackGroup trackGroup = groups.get(groupIndex); int[] trackFormatSupport = formatSupport[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - if (isSupported(trackFormatSupport[trackIndex])) { + if (isSupported(trackFormatSupport[trackIndex], exceedRendererCapabilitiesIfNecessary)) { Format format = trackGroup.getFormat(trackIndex); boolean isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; int trackScore; @@ -608,6 +642,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { } else { trackScore = 1; } + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } if (trackScore > selectedTrackScore) { selectedGroup = trackGroup; selectedTrackIndex = trackIndex; @@ -623,7 +660,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Text track selection implementation. protected TrackSelection selectTextTrack(TrackGroupArray groups, int[][] formatSupport, - String preferredTextLanguage, String preferredAudioLanguage) { + String preferredTextLanguage, String preferredAudioLanguage, + boolean exceedRendererCapabilitiesIfNecessary) { TrackGroup selectedGroup = null; int selectedTrackIndex = 0; int selectedTrackScore = 0; @@ -631,7 +669,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { TrackGroup trackGroup = groups.get(groupIndex); int[] trackFormatSupport = formatSupport[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - if (isSupported(trackFormatSupport[trackIndex])) { + if (isSupported(trackFormatSupport[trackIndex], exceedRendererCapabilitiesIfNecessary)) { Format format = trackGroup.getFormat(trackIndex); boolean isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; boolean isForced = (format.selectionFlags & C.SELECTION_FLAG_FORCED) != 0; @@ -656,7 +694,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { trackScore = 1; } } else { - trackScore = 0; + // Track should not be selected. + continue; + } + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; } if (trackScore > selectedTrackScore) { selectedGroup = trackGroup; @@ -673,7 +715,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { // General track selection methods. protected TrackSelection selectOtherTrack(int trackType, TrackGroupArray groups, - int[][] formatSupport) { + int[][] formatSupport, boolean exceedRendererCapabilitiesIfNecessary) { TrackGroup selectedGroup = null; int selectedTrackIndex = 0; int selectedTrackScore = 0; @@ -681,10 +723,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { TrackGroup trackGroup = groups.get(groupIndex); int[] trackFormatSupport = formatSupport[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - if (isSupported(trackFormatSupport[trackIndex])) { + if (isSupported(trackFormatSupport[trackIndex], exceedRendererCapabilitiesIfNecessary)) { Format format = trackGroup.getFormat(trackIndex); boolean isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; int trackScore = isDefault ? 2 : 1; + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } if (trackScore > selectedTrackScore) { selectedGroup = trackGroup; selectedTrackIndex = trackIndex; @@ -697,12 +742,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { : new FixedTrackSelection(selectedGroup, selectedTrackIndex); } - private static boolean isSupported(int formatSupport) { - return (formatSupport & RendererCapabilities.FORMAT_SUPPORT_MASK) - == RendererCapabilities.FORMAT_HANDLED; + protected static boolean isSupported(int formatSupport, boolean allowExceedsCapabilities) { + int maskedSupport = formatSupport & RendererCapabilities.FORMAT_SUPPORT_MASK; + return maskedSupport == RendererCapabilities.FORMAT_HANDLED || (allowExceedsCapabilities + && maskedSupport == RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES); } - private static boolean formatHasLanguage(Format format, String language) { + protected static boolean formatHasLanguage(Format format, String language) { return language != null && language.equals(Util.normalizeLanguageCode(format.language)); } diff --git a/library/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java b/library/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java index 3826ee4668..77df9a2173 100644 --- a/library/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java +++ b/library/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java @@ -15,14 +15,14 @@ */ package com.google.android.exoplayer2.trackselection; -import android.os.Handler; +import android.util.Pair; import android.util.SparseArray; import android.util.SparseBooleanArray; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; import java.util.HashMap; @@ -32,7 +32,7 @@ import java.util.Map; * Base class for {@link TrackSelector}s that first establish a mapping between {@link TrackGroup}s * and renderers, and then from that mapping create a {@link TrackSelection} for each renderer. */ -public abstract class MappingTrackSelector extends TrackSelector { +public abstract class MappingTrackSelector extends TrackSelector { /** * A track selection override. @@ -70,8 +70,8 @@ public abstract class MappingTrackSelector extends TrackSelector> selectionOverrides; private final SparseBooleanArray rendererDisabledFlags; - /** - * @param eventHandler A handler to use when delivering events to listeners added via - * {@link #addListener(EventListener)}. - */ - public MappingTrackSelector(Handler eventHandler) { - super(eventHandler); + private MappedTrackInfo currentMappedTrackInfo; + + public MappingTrackSelector() { selectionOverrides = new SparseArray<>(); rendererDisabledFlags = new SparseBooleanArray(); } + /** + * Returns the mapping information associated with the current track selections, or null if no + * selection is currently active. + */ + public final MappedTrackInfo getCurrentMappedTrackInfo() { + return currentMappedTrackInfo; + } + /** * Sets whether the renderer at the specified index is disabled. * @@ -134,8 +139,6 @@ public abstract class MappingTrackSelector extends TrackSelector overrides = selectionOverrides.get(rendererIndex); @@ -224,7 +227,7 @@ public abstract class MappingTrackSelector extends TrackSelector selectTracks( + public final Pair selectTracks( RendererCapabilities[] rendererCapabilities, TrackGroupArray trackGroups) throws ExoPlaybackException { // Structures into which data will be written during the selection. The extra item at the end @@ -294,7 +297,13 @@ public abstract class MappingTrackSelector extends TrackSelector(mappedTrackInfo, trackSelections); + return Pair.create(new TrackSelectionArray(trackSelections), + mappedTrackInfo); + } + + @Override + public final void onSelectionActivated(Object info) { + currentMappedTrackInfo = (MappedTrackInfo) info; } /** @@ -401,20 +410,29 @@ public abstract class MappingTrackSelector extends TrackSelector { +public final class TrackSelectionArray { - /** - * Opaque information associated with the result. - */ - public final T info; /** * The number of selections in the result. Greater than or equal to zero. */ @@ -37,11 +33,9 @@ public final class TrackSelections { private int hashCode; /** - * @param info Opaque information associated with the result. * @param trackSelections The selections. Must not be null, but may contain null elements. */ - public TrackSelections(T info, TrackSelection... trackSelections) { - this.info = info; + public TrackSelectionArray(TrackSelection... trackSelections) { this.trackSelections = trackSelections; this.length = trackSelections.length; } @@ -81,7 +75,7 @@ public final class TrackSelections { if (obj == null || getClass() != obj.getClass()) { return false; } - TrackSelections other = (TrackSelections) obj; + TrackSelectionArray other = (TrackSelectionArray) obj; return Arrays.equals(trackSelections, other.trackSelections); } diff --git a/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java index 41c62f6e0e..5a9d3923bf 100644 --- a/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java +++ b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java @@ -15,15 +15,13 @@ */ package com.google.android.exoplayer2.trackselection; -import android.os.Handler; +import android.util.Pair; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.util.Assertions; -import java.util.concurrent.CopyOnWriteArraySet; /** Selects tracks to be consumed by available renderers. */ -public abstract class TrackSelector { +public abstract class TrackSelector { /** * Notified when previous selections by a {@link TrackSelector} are no longer valid. @@ -37,55 +35,7 @@ public abstract class TrackSelector { } - /** Listener of {@link TrackSelector} events. */ - public interface EventListener { - - /** - * Called when the track selections have changed. - * - * @param trackSelections The new track selections. - */ - void onTrackSelectionsChanged(TrackSelections trackSelections); - } - - private final Handler eventHandler; - private final CopyOnWriteArraySet> listeners; - private InvalidationListener listener; - private TrackSelections activeSelections; - - /** - * @param eventHandler A handler to use when delivering events to listeners added via {@link - * #addListener(EventListener)}. - */ - public TrackSelector(Handler eventHandler) { - this.eventHandler = Assertions.checkNotNull(eventHandler); - this.listeners = new CopyOnWriteArraySet<>(); - } - - /** - * Registers a listener to receive events from the selector. The listener's methods will be called - * using the {@link Handler} that was passed to the constructor. - * - * @param listener The listener to register. - */ - public final void addListener(EventListener listener) { - listeners.add(listener); - } - - /** - * Unregister a listener. The listener will no longer receive events from the selector. - * - * @param listener The listener to unregister. - */ - public final void removeListener(EventListener listener) { - listeners.remove(listener); - } - - /** Returns the current track selections. */ - public final TrackSelections getCurrentSelections() { - return activeSelections; - } /** * Initializes the selector. @@ -97,28 +47,27 @@ public abstract class TrackSelector { } /** - * Generates {@link TrackSelections} for the renderers. + * Generates {@link TrackSelectionArray} for the renderers. * - * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which {@link - * TrackSelection}s are to be generated. + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which + * {@link TrackSelection}s are to be generated. * @param trackGroups The available track groups. - * @return The track selections. + * @return The track selections, and an implementation specific object that will be returned to + * the selector via {@link #onSelectionActivated(Object)} should the selections be activated. * @throws ExoPlaybackException If an error occurs selecting tracks. */ - public abstract TrackSelections selectTracks( + public abstract Pair selectTracks( RendererCapabilities[] rendererCapabilities, TrackGroupArray trackGroups) throws ExoPlaybackException; /** - * Called when {@link TrackSelections} previously generated by {@link - * #selectTracks(RendererCapabilities[], TrackGroupArray)} are activated. + * Called when {@link TrackSelectionArray} previously generated by + * {@link #selectTracks(RendererCapabilities[], TrackGroupArray)} are activated. * - * @param activeSelections The activated {@link TrackSelections}. + * @param info The information associated with the selections, or null if the selected tracks are + * being cleared. */ - public final void onSelectionActivated(TrackSelections activeSelections) { - this.activeSelections = activeSelections; - notifyTrackSelectionsChanged(activeSelections); - } + public abstract void onSelectionActivated(Object info); /** * Invalidates all previously generated track selections. @@ -129,18 +78,4 @@ public abstract class TrackSelector { } } - private void notifyTrackSelectionsChanged(final TrackSelections activeSelections) { - if (eventHandler != null) { - eventHandler.post( - new Runnable() { - @Override - public void run() { - for (EventListener listener : listeners) { - listener.onTrackSelectionsChanged(activeSelections); - } - } - }); - } - } - } diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/AspectRatioFrameLayout.java b/library/src/main/java/com/google/android/exoplayer2/ui/AspectRatioFrameLayout.java index e6f18c882b..d3034a8bc8 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/AspectRatioFrameLayout.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/AspectRatioFrameLayout.java @@ -17,16 +17,26 @@ package com.google.android.exoplayer2.ui; import android.content.Context; import android.content.res.TypedArray; +import android.support.annotation.IntDef; import android.util.AttributeSet; import android.widget.FrameLayout; import com.google.android.exoplayer2.R; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; /** * A {@link FrameLayout} that resizes itself to match a specified aspect ratio. */ public final class AspectRatioFrameLayout extends FrameLayout { + /** + * Resize modes for {@link AspectRatioFrameLayout}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({RESIZE_MODE_FIT, RESIZE_MODE_FIXED_WIDTH, RESIZE_MODE_FIXED_HEIGHT, RESIZE_MODE_FILL}) + public @interface ResizeMode {} + /** * Either the width or height is decreased to obtain the desired aspect ratio. */ @@ -39,6 +49,10 @@ public final class AspectRatioFrameLayout extends FrameLayout { * The height is fixed and the width is increased or decreased to obtain the desired aspect ratio. */ public static final int RESIZE_MODE_FIXED_HEIGHT = 2; + /** + * The specified aspect ratio is ignored. + */ + public static final int RESIZE_MODE_FILL = 3; /** * The {@link FrameLayout} will not resize itself if the fractional difference between its natural @@ -85,12 +99,11 @@ public final class AspectRatioFrameLayout extends FrameLayout { } /** - * Sets the resize mode which can be of value {@link #RESIZE_MODE_FIT}, - * {@link #RESIZE_MODE_FIXED_HEIGHT} or {@link #RESIZE_MODE_FIXED_WIDTH}. + * Sets the resize mode. * * @param resizeMode The resize mode. */ - public void setResizeMode(int resizeMode) { + public void setResizeMode(@ResizeMode int resizeMode) { if (this.resizeMode != resizeMode) { this.resizeMode = resizeMode; requestLayout(); @@ -100,7 +113,7 @@ public final class AspectRatioFrameLayout extends FrameLayout { @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (videoAspectRatio == 0) { + if (resizeMode == RESIZE_MODE_FILL || videoAspectRatio <= 0) { // Aspect ratio not set. return; } diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java b/library/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java index af38836fc9..1bf5b59a4a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java @@ -22,6 +22,8 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; /** * A helper class for periodically updating a {@link TextView} with debug information obtained from @@ -98,6 +100,11 @@ public final class DebugTextViewHelper implements Runnable, ExoPlayer.EventListe // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Do nothing. + } + // Runnable implementation. @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java b/library/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java index 89c778d072..40e814dab3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java @@ -24,7 +24,6 @@ import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.widget.FrameLayout; -import android.widget.ImageButton; import android.widget.SeekBar; import android.widget.TextView; import com.google.android.exoplayer2.C; @@ -32,12 +31,116 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.R; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.util.Util; import java.util.Formatter; import java.util.Locale; /** - * A view to control video playback of an {@link ExoPlayer}. + * A view for controlling {@link ExoPlayer} instances. + *

    + * A PlaybackControlView can be customized by setting attributes (or calling corresponding methods), + * overriding the view's layout file or by specifying a custom view layout file, as outlined below. + * + *

    Attributes

    + * The following attributes can be set on a PlaybackControlView when used in a layout XML file: + *

    + *

      + *
    • {@code show_timeout} - The time between the last user interaction and the controls + * being automatically hidden, in milliseconds. Use zero if the controls should not + * automatically timeout. + *
        + *
      • Corresponding method: {@link #setShowTimeoutMs(int)}
      • + *
      • Default: {@link #DEFAULT_SHOW_TIMEOUT_MS}
      • + *
      + *
    • + *
    • {@code rewind_increment} - The duration of the rewind applied when the user taps the + * rewind button, in milliseconds. Use zero to disable the rewind button. + *
        + *
      • Corresponding method: {@link #setRewindIncrementMs(int)}
      • + *
      • Default: {@link #DEFAULT_REWIND_MS}
      • + *
      + *
    • + *
    • {@code fastforward_increment} - Like {@code rewind_increment}, but for fast forward. + *
        + *
      • Corresponding method: {@link #setFastForwardIncrementMs(int)}
      • + *
      • Default: {@link #DEFAULT_FAST_FORWARD_MS}
      • + *
      + *
    • + *
    • {@code controller_layout_id} - Specifies the id of the layout to be inflated. See + * below for more details. + *
        + *
      • Corresponding method: None
      • + *
      • Default: {@code R.id.exo_playback_control_view}
      • + *
      + *
    • + *
    + * + *

    Overriding the layout file

    + * To customize the layout of PlaybackControlView throughout your app, or just for certain + * configurations, you can define {@code exo_playback_control_view.xml} layout files in your + * application {@code res/layout*} directories. These layouts will override the one provided by the + * ExoPlayer library, and will be inflated for use by PlaybackControlView. The view identifies and + * binds its children by looking for the following ids: + *

    + *

      + *
    • {@code exo_play} - The play button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_pause} - The pause button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_ffwd} - The fast forward button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_rew} - The rewind button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_prev} - The previous track button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_next} - The next track button. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_position} - Text view displaying the current playback position. + *
        + *
      • Type: {@link TextView}
      • + *
      + *
    • + *
    • {@code exo_duration} - Text view displaying the current media duration. + *
        + *
      • Type: {@link TextView}
      • + *
      + *
    • + *
    • {@code exo_progress} - Seek bar that's updated during playback and allows seeking. + *
        + *
      • Type: {@link SeekBar}
      • + *
      + *
    • + *
    + *

    + * All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

    Specifying a custom layout file

    + * Defining your own {@code exo_playback_control_view.xml} is useful to customize the layout of + * PlaybackControlView throughout your application. It's also possible to customize the layout for a + * single instance in a layout file. This is achieved by setting the {@code controller_layout_id} + * attribute on a PlaybackControlView. This will cause the specified layout to be inflated instead + * of {@code exo_playback_control_view.xml} for only the instance on which the attribute is set. */ public class PlaybackControlView extends FrameLayout { @@ -45,14 +148,45 @@ public class PlaybackControlView extends FrameLayout { * Listener to be notified about changes of the visibility of the UI control. */ public interface VisibilityListener { + /** * Called when the visibility changes. * * @param visibility The new visibility. Either {@link View#VISIBLE} or {@link View#GONE}. */ void onVisibilityChange(int visibility); + } + /** + * Dispatches seek operations to the player. + */ + public interface SeekDispatcher { + + /** + * @param player The player to seek. + * @param windowIndex The index of the window. + * @param positionMs The seek position in the specified window, or {@link C#TIME_UNSET} to seek + * to the window's default position. + * @return True if the seek was dispatched. False otherwise. + */ + boolean dispatchSeek(ExoPlayer player, int windowIndex, long positionMs); + + } + + /** + * Default {@link SeekDispatcher} that dispatches seeks to the player without modification. + */ + public static final SeekDispatcher DEFAULT_SEEK_DISPATCHER = new SeekDispatcher() { + + @Override + public boolean dispatchSeek(ExoPlayer player, int windowIndex, long positionMs) { + player.seekTo(windowIndex, positionMs); + return true; + } + + }; + public static final int DEFAULT_FAST_FORWARD_MS = 15000; public static final int DEFAULT_REWIND_MS = 5000; public static final int DEFAULT_SHOW_TIMEOUT_MS = 5000; @@ -63,17 +197,19 @@ public class PlaybackControlView extends FrameLayout { private final ComponentListener componentListener; private final View previousButton; private final View nextButton; - private final ImageButton playButton; - private final TextView time; - private final TextView timeCurrent; - private final SeekBar progressBar; + private final View playButton; + private final View pauseButton; private final View fastForwardButton; private final View rewindButton; + private final TextView durationView; + private final TextView positionView; + private final SeekBar progressBar; private final StringBuilder formatBuilder; private final Formatter formatter; private final Timeline.Window currentWindow; private ExoPlayer player; + private SeekDispatcher seekDispatcher; private VisibilityListener visibilityListener; private boolean isAttachedToWindow; @@ -108,6 +244,7 @@ public class PlaybackControlView extends FrameLayout { public PlaybackControlView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); + int controllerLayoutId = R.layout.exo_playback_control_view; rewindMs = DEFAULT_REWIND_MS; fastForwardMs = DEFAULT_FAST_FORWARD_MS; showTimeoutMs = DEFAULT_SHOW_TIMEOUT_MS; @@ -119,32 +256,52 @@ public class PlaybackControlView extends FrameLayout { fastForwardMs = a.getInt(R.styleable.PlaybackControlView_fastforward_increment, fastForwardMs); showTimeoutMs = a.getInt(R.styleable.PlaybackControlView_show_timeout, showTimeoutMs); + controllerLayoutId = a.getResourceId(R.styleable.PlaybackControlView_controller_layout_id, + controllerLayoutId); } finally { a.recycle(); } } - currentWindow = new Timeline.Window(); formatBuilder = new StringBuilder(); formatter = new Formatter(formatBuilder, Locale.getDefault()); componentListener = new ComponentListener(); + seekDispatcher = DEFAULT_SEEK_DISPATCHER; - LayoutInflater.from(context).inflate(R.layout.exo_playback_control_view, this); - time = (TextView) findViewById(R.id.time); - timeCurrent = (TextView) findViewById(R.id.time_current); - progressBar = (SeekBar) findViewById(R.id.mediacontroller_progress); - progressBar.setOnSeekBarChangeListener(componentListener); - progressBar.setMax(PROGRESS_BAR_MAX); - playButton = (ImageButton) findViewById(R.id.play); - playButton.setOnClickListener(componentListener); - previousButton = findViewById(R.id.prev); - previousButton.setOnClickListener(componentListener); - nextButton = findViewById(R.id.next); - nextButton.setOnClickListener(componentListener); - rewindButton = findViewById(R.id.rew); - rewindButton.setOnClickListener(componentListener); - fastForwardButton = findViewById(R.id.ffwd); - fastForwardButton.setOnClickListener(componentListener); + LayoutInflater.from(context).inflate(controllerLayoutId, this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + durationView = (TextView) findViewById(R.id.exo_duration); + positionView = (TextView) findViewById(R.id.exo_position); + progressBar = (SeekBar) findViewById(R.id.exo_progress); + if (progressBar != null) { + progressBar.setOnSeekBarChangeListener(componentListener); + progressBar.setMax(PROGRESS_BAR_MAX); + } + playButton = findViewById(R.id.exo_play); + if (playButton != null) { + playButton.setOnClickListener(componentListener); + } + pauseButton = findViewById(R.id.exo_pause); + if (pauseButton != null) { + pauseButton.setOnClickListener(componentListener); + } + previousButton = findViewById(R.id.exo_prev); + if (previousButton != null) { + previousButton.setOnClickListener(componentListener); + } + nextButton = findViewById(R.id.exo_next); + if (nextButton != null) { + nextButton.setOnClickListener(componentListener); + } + rewindButton = findViewById(R.id.exo_rew); + if (rewindButton != null) { + rewindButton.setOnClickListener(componentListener); + } + fastForwardButton = findViewById(R.id.exo_ffwd); + if (fastForwardButton != null) { + fastForwardButton.setOnClickListener(componentListener); + } } /** @@ -182,10 +339,21 @@ public class PlaybackControlView extends FrameLayout { this.visibilityListener = listener; } + /** + * Sets the {@link SeekDispatcher}. + * + * @param seekDispatcher The {@link SeekDispatcher}, or null to use + * {@link #DEFAULT_SEEK_DISPATCHER}. + */ + public void setSeekDispatcher(SeekDispatcher seekDispatcher) { + this.seekDispatcher = seekDispatcher == null ? DEFAULT_SEEK_DISPATCHER : seekDispatcher; + } + /** * Sets the rewind increment in milliseconds. * - * @param rewindMs The rewind increment in milliseconds. + * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the + * rewind button to be disabled. */ public void setRewindIncrementMs(int rewindMs) { this.rewindMs = rewindMs; @@ -195,7 +363,8 @@ public class PlaybackControlView extends FrameLayout { /** * Sets the fast forward increment in milliseconds. * - * @param fastForwardMs The fast forward increment in milliseconds. + * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will + * cause the fast forward button to be disabled. */ public void setFastForwardIncrementMs(int fastForwardMs) { this.fastForwardMs = fastForwardMs; @@ -235,6 +404,7 @@ public class PlaybackControlView extends FrameLayout { visibilityListener.onVisibilityChange(getVisibility()); } updateAll(); + requestPlayPauseFocus(); } // Call hideAfterTimeout even if already visible to reset the timeout. hideAfterTimeout(); @@ -284,12 +454,19 @@ public class PlaybackControlView extends FrameLayout { if (!isVisible() || !isAttachedToWindow) { return; } + boolean requestPlayPauseFocus = false; boolean playing = player != null && player.getPlayWhenReady(); - String contentDescription = getResources().getString( - playing ? R.string.exo_controls_pause_description : R.string.exo_controls_play_description); - playButton.setContentDescription(contentDescription); - playButton.setImageResource( - playing ? R.drawable.exo_controls_pause : R.drawable.exo_controls_play); + if (playButton != null) { + requestPlayPauseFocus |= playing && playButton.isFocused(); + playButton.setVisibility(playing ? View.GONE : View.VISIBLE); + } + if (pauseButton != null) { + requestPlayPauseFocus |= !playing && pauseButton.isFocused(); + pauseButton.setVisibility(!playing ? View.GONE : View.VISIBLE); + } + if (requestPlayPauseFocus) { + requestPlayPauseFocus(); + } } private void updateNavigation() { @@ -297,11 +474,11 @@ public class PlaybackControlView extends FrameLayout { return; } Timeline currentTimeline = player != null ? player.getCurrentTimeline() : null; - boolean haveTimeline = currentTimeline != null; + boolean haveNonEmptyTimeline = currentTimeline != null && !currentTimeline.isEmpty(); boolean isSeekable = false; boolean enablePrevious = false; boolean enableNext = false; - if (haveTimeline) { + if (haveNonEmptyTimeline) { int currentWindowIndex = player.getCurrentWindowIndex(); currentTimeline.getWindow(currentWindowIndex, currentWindow); isSeekable = currentWindow.isSeekable; @@ -313,7 +490,9 @@ public class PlaybackControlView extends FrameLayout { setButtonEnabled(enableNext, nextButton); setButtonEnabled(fastForwardMs > 0 && isSeekable, fastForwardButton); setButtonEnabled(rewindMs > 0 && isSeekable, rewindButton); - progressBar.setEnabled(isSeekable); + if (progressBar != null) { + progressBar.setEnabled(isSeekable); + } } private void updateProgress() { @@ -322,16 +501,21 @@ public class PlaybackControlView extends FrameLayout { } long duration = player == null ? 0 : player.getDuration(); long position = player == null ? 0 : player.getCurrentPosition(); - time.setText(stringForTime(duration)); - if (!dragging) { - timeCurrent.setText(stringForTime(position)); + if (durationView != null) { + durationView.setText(stringForTime(duration)); } - if (!dragging) { - progressBar.setProgress(progressBarValue(position)); + if (positionView != null && !dragging) { + positionView.setText(stringForTime(position)); + } + + if (progressBar != null) { + if (!dragging) { + progressBar.setProgress(progressBarValue(position)); + } + long bufferedPosition = player == null ? 0 : player.getBufferedPosition(); + progressBar.setSecondaryProgress(progressBarValue(bufferedPosition)); + // Remove scheduled updates. } - long bufferedPosition = player == null ? 0 : player.getBufferedPosition(); - progressBar.setSecondaryProgress(progressBarValue(bufferedPosition)); - // Remove scheduled updates. removeCallbacks(updateProgressAction); // Schedule an update if necessary. int playbackState = player == null ? ExoPlayer.STATE_IDLE : player.getPlaybackState(); @@ -349,7 +533,19 @@ public class PlaybackControlView extends FrameLayout { } } + private void requestPlayPauseFocus() { + boolean playing = player != null && player.getPlayWhenReady(); + if (!playing && playButton != null) { + playButton.requestFocus(); + } else if (playing && pauseButton != null) { + pauseButton.requestFocus(); + } + } + private void setButtonEnabled(boolean enabled, View view) { + if (view == null) { + return; + } view.setEnabled(enabled); if (Util.SDK_INT >= 11) { setViewAlphaV11(view, enabled ? 1f : 0.3f); @@ -390,29 +586,29 @@ public class PlaybackControlView extends FrameLayout { private void previous() { Timeline currentTimeline = player.getCurrentTimeline(); - if (currentTimeline == null) { + if (currentTimeline.isEmpty()) { return; } int currentWindowIndex = player.getCurrentWindowIndex(); currentTimeline.getWindow(currentWindowIndex, currentWindow); if (currentWindowIndex > 0 && (player.getCurrentPosition() <= MAX_POSITION_FOR_SEEK_TO_PREVIOUS || (currentWindow.isDynamic && !currentWindow.isSeekable))) { - player.seekToDefaultPosition(currentWindowIndex - 1); + seekTo(currentWindowIndex - 1, C.TIME_UNSET); } else { - player.seekTo(0); + seekTo(0); } } private void next() { Timeline currentTimeline = player.getCurrentTimeline(); - if (currentTimeline == null) { + if (currentTimeline.isEmpty()) { return; } int currentWindowIndex = player.getCurrentWindowIndex(); if (currentWindowIndex < currentTimeline.getWindowCount() - 1) { - player.seekToDefaultPosition(currentWindowIndex + 1); + seekTo(currentWindowIndex + 1, C.TIME_UNSET); } else if (currentTimeline.getWindow(currentWindowIndex, currentWindow, false).isDynamic) { - player.seekToDefaultPosition(); + seekTo(currentWindowIndex, C.TIME_UNSET); } } @@ -420,14 +616,27 @@ public class PlaybackControlView extends FrameLayout { if (rewindMs <= 0) { return; } - player.seekTo(Math.max(player.getCurrentPosition() - rewindMs, 0)); + seekTo(Math.max(player.getCurrentPosition() - rewindMs, 0)); } private void fastForward() { if (fastForwardMs <= 0) { return; } - player.seekTo(Math.min(player.getCurrentPosition() + fastForwardMs, player.getDuration())); + seekTo(Math.min(player.getCurrentPosition() + fastForwardMs, player.getDuration())); + } + + private void seekTo(long positionMs) { + seekTo(player.getCurrentWindowIndex(), positionMs); + } + + private void seekTo(int windowIndex, long positionMs) { + boolean dispatched = seekDispatcher.dispatchSeek(player, windowIndex, positionMs); + if (!dispatched) { + // The seek wasn't dispatched. If the progress bar was dragged by the user to perform the + // seek then it'll now be in the wrong position. Trigger a progress update to snap it back. + updateProgress(); + } } @Override @@ -455,40 +664,66 @@ public class PlaybackControlView extends FrameLayout { @Override public boolean dispatchKeyEvent(KeyEvent event) { - if (player == null || event.getAction() != KeyEvent.ACTION_DOWN) { - return super.dispatchKeyEvent(event); + boolean handled = dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event); + if (handled) { + show(); } - switch (event.getKeyCode()) { - case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: - case KeyEvent.KEYCODE_DPAD_RIGHT: - fastForward(); - break; - case KeyEvent.KEYCODE_MEDIA_REWIND: - case KeyEvent.KEYCODE_DPAD_LEFT: - rewind(); - break; - case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - player.setPlayWhenReady(!player.getPlayWhenReady()); - break; - case KeyEvent.KEYCODE_MEDIA_PLAY: - player.setPlayWhenReady(true); - break; - case KeyEvent.KEYCODE_MEDIA_PAUSE: - player.setPlayWhenReady(false); - break; - case KeyEvent.KEYCODE_MEDIA_NEXT: - next(); - break; - case KeyEvent.KEYCODE_MEDIA_PREVIOUS: - previous(); - break; - default: - return false; + return handled; + } + + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + if (player == null || !isHandledMediaKey(keyCode)) { + return false; + } + if (event.getAction() == KeyEvent.ACTION_DOWN) { + switch (keyCode) { + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + fastForward(); + break; + case KeyEvent.KEYCODE_MEDIA_REWIND: + rewind(); + break; + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + player.setPlayWhenReady(!player.getPlayWhenReady()); + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + player.setPlayWhenReady(true); + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + player.setPlayWhenReady(false); + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + next(); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + previous(); + break; + default: + break; + } } show(); return true; } + private static boolean isHandledMediaKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY + || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS; + } + private final class ComponentListener implements ExoPlayer.EventListener, SeekBar.OnSeekBarChangeListener, OnClickListener { @@ -500,15 +735,17 @@ public class PlaybackControlView extends FrameLayout { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser) { - timeCurrent.setText(stringForTime(positionValue(progress))); + if (fromUser && positionView != null) { + positionView.setText(stringForTime(positionValue(progress))); } } @Override public void onStopTrackingTouch(SeekBar seekBar) { dragging = false; - player.seekTo(positionValue(seekBar.getProgress())); + if (player != null) { + seekTo(positionValue(seekBar.getProgress())); + } hideAfterTimeout(); } @@ -535,6 +772,11 @@ public class PlaybackControlView extends FrameLayout { // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Do nothing. + } + @Override public void onPlayerError(ExoPlaybackException error) { // Do nothing. @@ -542,17 +784,20 @@ public class PlaybackControlView extends FrameLayout { @Override public void onClick(View view) { - Timeline currentTimeline = player.getCurrentTimeline(); - if (nextButton == view) { - next(); - } else if (previousButton == view) { - previous(); - } else if (fastForwardButton == view) { - fastForward(); - } else if (rewindButton == view && currentTimeline != null) { - rewind(); - } else if (playButton == view) { - player.setPlayWhenReady(!player.getPlayWhenReady()); + if (player != null) { + if (nextButton == view) { + next(); + } else if (previousButton == view) { + previous(); + } else if (fastForwardButton == view) { + fastForward(); + } else if (rewindButton == view) { + rewind(); + } else if (playButton == view) { + player.setPlayWhenReady(true); + } else if (pauseButton == view) { + player.setPlayWhenReady(false); + } } hideAfterTimeout(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java b/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java index 51955ccef3..d094266fcc 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java @@ -18,6 +18,8 @@ package com.google.android.exoplayer2.ui; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -27,30 +29,156 @@ import android.view.TextureView; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import android.widget.ImageView; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.R; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextRenderer; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; +import com.google.android.exoplayer2.ui.PlaybackControlView.SeekDispatcher; +import com.google.android.exoplayer2.util.Assertions; import java.util.List; /** - * Displays a video stream. + * A high level view for {@link SimpleExoPlayer} media playbacks. It displays video, subtitles and + * album art during playback, and displays playback controls using a {@link PlaybackControlView}. + *

    + * A SimpleExoPlayerView can be customized by setting attributes (or calling corresponding methods), + * overriding the view's layout file or by specifying a custom view layout file, as outlined below. + * + *

    Attributes

    + * The following attributes can be set on a SimpleExoPlayerView when used in a layout XML file: + *

    + *

      + *
    • {@code use_artwork} - Whether artwork is used if available in audio streams. + *
        + *
      • Corresponding method: {@link #setUseArtwork(boolean)}
      • + *
      • Default: {@code true}
      • + *
      + *
    • + *
    • {@code use_controller} - Whether playback controls are displayed. + *
        + *
      • Corresponding method: {@link #setUseController(boolean)}
      • + *
      • Default: {@code true}
      • + *
      + *
    • + *
    • {@code resize_mode} - Controls how video and album art is resized within the view. + * Valid values are {@code fit}, {@code fixed_width}, {@code fixed_height} and {@code fill}. + *
        + *
      • Corresponding method: {@link #setResizeMode(int)}
      • + *
      • Default: {@code fit}
      • + *
      + *
    • + *
    • {@code surface_type} - The type of surface view used for video playbacks. Valid + * values are {@code surface_view}, {@code texture_view} and {@code none}. Using {@code none} + * is recommended for audio only applications, since creating the surface can be expensive. + * Using {@code surface_view} is recommended for video applications. + *
        + *
      • Corresponding method: None
      • + *
      • Default: {@code surface_view}
      • + *
      + *
    • + *
    • {@code player_layout_id} - Specifies the id of the layout to be inflated. See below + * for more details. + *
        + *
      • Corresponding method: None
      • + *
      • Default: {@code R.id.exo_simple_player_view}
      • + *
      + *
    • {@code controller_layout_id} - Specifies the id of the layout resource to be + * inflated by the child {@link PlaybackControlView}. See below for more details. + *
        + *
      • Corresponding method: None
      • + *
      • Default: {@code R.id.exo_playback_control_view}
      • + *
      + *
    • All attributes that can be set on a {@link PlaybackControlView} can also be set on a + * SimpleExoPlayerView, and will be propagated to the inflated {@link PlaybackControlView}. + *
    • + *
    + * + *

    Overriding the layout file

    + * To customize the layout of SimpleExoPlayerView throughout your app, or just for certain + * configurations, you can define {@code exo_simple_player_view.xml} layout files in your + * application {@code res/layout*} directories. These layouts will override the one provided by the + * ExoPlayer library, and will be inflated for use by SimpleExoPlayerView. The view identifies and + * binds its children by looking for the following ids: + *

    + *

      + *
    • {@code exo_content_frame} - A frame whose aspect ratio is resized based on the video + * or album art of the media being played, and the configured {@code resize_mode}. The video + * surface view is inflated into this frame as its first child. + *
        + *
      • Type: {@link AspectRatioFrameLayout}
      • + *
      + *
    • + *
    • {@code exo_shutter} - A view that's made visible when video should be hidden. This + * view is typically an opaque view that covers the video surface view, thereby obscuring it + * when visible. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_subtitles} - Displays subtitles. + *
        + *
      • Type: {@link SubtitleView}
      • + *
      + *
    • + *
    • {@code exo_artwork} - Displays album art. + *
        + *
      • Type: {@link ImageView}
      • + *
      + *
    • + *
    • {@code exo_controller_placeholder} - A placeholder that's replaced with the inflated + * {@link PlaybackControlView}. + *
        + *
      • Type: {@link View}
      • + *
      + *
    • + *
    • {@code exo_overlay} - A {@link FrameLayout} positioned on top of the player which + * the app can access via {@link #getOverlayFrameLayout()}, provided for convenience. + *
        + *
      • Type: {@link FrameLayout}
      • + *
      + *
    • + *
    + *

    + * All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

    Specifying a custom layout file

    + * Defining your own {@code exo_simple_player_view.xml} is useful to customize the layout of + * SimpleExoPlayerView throughout your application. It's also possible to customize the layout for a + * single instance in a layout file. This is achieved by setting the {@code player_layout_id} + * attribute on a SimpleExoPlayerView. This will cause the specified layout to be inflated instead + * of {@code exo_simple_player_view.xml} for only the instance on which the attribute is set. */ @TargetApi(16) public final class SimpleExoPlayerView extends FrameLayout { - private final View surfaceView; + private static final int SURFACE_TYPE_NONE = 0; + private static final int SURFACE_TYPE_SURFACE_VIEW = 1; + private static final int SURFACE_TYPE_TEXTURE_VIEW = 2; + + private final AspectRatioFrameLayout contentFrame; private final View shutterView; - private final SubtitleView subtitleLayout; - private final AspectRatioFrameLayout layout; + private final View surfaceView; + private final ImageView artworkView; + private final SubtitleView subtitleView; private final PlaybackControlView controller; private final ComponentListener componentListener; + private final FrameLayout overlayFrameLayout; private SimpleExoPlayer player; - private boolean useController = true; + private boolean useController; + private boolean useArtwork; private int controllerShowTimeoutMs; public SimpleExoPlayerView(Context context) { @@ -64,23 +192,22 @@ public final class SimpleExoPlayerView extends FrameLayout { public SimpleExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); - boolean useTextureView = false; + int playerLayoutId = R.layout.exo_simple_player_view; + boolean useArtwork = true; + boolean useController = true; + int surfaceType = SURFACE_TYPE_SURFACE_VIEW; int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; - int rewindMs = PlaybackControlView.DEFAULT_REWIND_MS; - int fastForwardMs = PlaybackControlView.DEFAULT_FAST_FORWARD_MS; int controllerShowTimeoutMs = PlaybackControlView.DEFAULT_SHOW_TIMEOUT_MS; if (attrs != null) { TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SimpleExoPlayerView, 0, 0); try { + playerLayoutId = a.getResourceId(R.styleable.SimpleExoPlayerView_player_layout_id, + playerLayoutId); + useArtwork = a.getBoolean(R.styleable.SimpleExoPlayerView_use_artwork, useArtwork); useController = a.getBoolean(R.styleable.SimpleExoPlayerView_use_controller, useController); - useTextureView = a.getBoolean(R.styleable.SimpleExoPlayerView_use_texture_view, - useTextureView); - resizeMode = a.getInt(R.styleable.SimpleExoPlayerView_resize_mode, - AspectRatioFrameLayout.RESIZE_MODE_FIT); - rewindMs = a.getInt(R.styleable.SimpleExoPlayerView_rewind_increment, rewindMs); - fastForwardMs = a.getInt(R.styleable.SimpleExoPlayerView_fastforward_increment, - fastForwardMs); + surfaceType = a.getInt(R.styleable.SimpleExoPlayerView_surface_type, surfaceType); + resizeMode = a.getInt(R.styleable.SimpleExoPlayerView_resize_mode, resizeMode); controllerShowTimeoutMs = a.getInt(R.styleable.SimpleExoPlayerView_show_timeout, controllerShowTimeoutMs); } finally { @@ -88,28 +215,62 @@ public final class SimpleExoPlayerView extends FrameLayout { } } - LayoutInflater.from(context).inflate(R.layout.exo_simple_player_view, this); + LayoutInflater.from(context).inflate(playerLayoutId, this); componentListener = new ComponentListener(); - layout = (AspectRatioFrameLayout) findViewById(R.id.video_frame); - layout.setResizeMode(resizeMode); - shutterView = findViewById(R.id.shutter); - subtitleLayout = (SubtitleView) findViewById(R.id.subtitles); - subtitleLayout.setUserDefaultStyle(); - subtitleLayout.setUserDefaultTextSize(); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); - controller = (PlaybackControlView) findViewById(R.id.control); - controller.hide(); - controller.setRewindIncrementMs(rewindMs); - controller.setFastForwardIncrementMs(fastForwardMs); - this.controllerShowTimeoutMs = controllerShowTimeoutMs; + // Content frame. + contentFrame = (AspectRatioFrameLayout) findViewById(R.id.exo_content_frame); + if (contentFrame != null) { + setResizeModeRaw(contentFrame, resizeMode); + } - View view = useTextureView ? new TextureView(context) : new SurfaceView(context); - ViewGroup.LayoutParams params = new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT); - view.setLayoutParams(params); - surfaceView = view; - layout.addView(surfaceView, 0); + // Shutter view. + shutterView = findViewById(R.id.exo_shutter); + + // Create a surface view and insert it into the content frame, if there is one. + if (contentFrame != null && surfaceType != SURFACE_TYPE_NONE) { + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + surfaceView = surfaceType == SURFACE_TYPE_TEXTURE_VIEW ? new TextureView(context) + : new SurfaceView(context); + surfaceView.setLayoutParams(params); + contentFrame.addView(surfaceView, 0); + } else { + surfaceView = null; + } + + // Overlay frame layout. + overlayFrameLayout = (FrameLayout) findViewById(R.id.exo_overlay); + + // Artwork view. + artworkView = (ImageView) findViewById(R.id.exo_artwork); + this.useArtwork = useArtwork && artworkView != null; + + // Subtitle view. + subtitleView = (SubtitleView) findViewById(R.id.exo_subtitles); + if (subtitleView != null) { + subtitleView.setUserDefaultStyle(); + subtitleView.setUserDefaultTextSize(); + } + + // Playback control view. + View controllerPlaceholder = findViewById(R.id.exo_controller_placeholder); + if (controllerPlaceholder != null) { + // Note: rewindMs and fastForwardMs are passed via attrs, so we don't need to make explicit + // calls to set them. + this.controller = new PlaybackControlView(context, attrs); + controller.setLayoutParams(controllerPlaceholder.getLayoutParams()); + ViewGroup parent = ((ViewGroup) controllerPlaceholder.getParent()); + int controllerIndex = parent.indexOfChild(controllerPlaceholder); + parent.removeView(controllerPlaceholder); + parent.addView(controller, controllerIndex); + } else { + this.controller = null; + } + this.controllerShowTimeoutMs = controller != null ? controllerShowTimeoutMs : 0; + this.useController = useController && controller != null; + hideController(); } /** @@ -140,6 +301,9 @@ public final class SimpleExoPlayerView extends FrameLayout { if (useController) { controller.setPlayer(player); } + if (shutterView != null) { + shutterView.setVisibility(VISIBLE); + } if (player != null) { if (surfaceView instanceof TextureView) { player.setVideoTextureView((TextureView) surfaceView); @@ -150,21 +314,41 @@ public final class SimpleExoPlayerView extends FrameLayout { player.addListener(componentListener); player.setTextOutput(componentListener); maybeShowController(false); + updateForCurrentTrackSelections(); } else { - shutterView.setVisibility(VISIBLE); - controller.hide(); + hideController(); + hideArtwork(); } } /** - * Sets the resize mode which can be of value {@link AspectRatioFrameLayout#RESIZE_MODE_FIT}, - * {@link AspectRatioFrameLayout#RESIZE_MODE_FIXED_HEIGHT} or - * {@link AspectRatioFrameLayout#RESIZE_MODE_FIXED_WIDTH}. + * Sets the resize mode. * * @param resizeMode The resize mode. */ - public void setResizeMode(int resizeMode) { - layout.setResizeMode(resizeMode); + public void setResizeMode(@ResizeMode int resizeMode) { + Assertions.checkState(contentFrame != null); + contentFrame.setResizeMode(resizeMode); + } + + /** + * Returns whether artwork is displayed if present in the media. + */ + public boolean getUseArtwork() { + return useArtwork; + } + + /** + * Sets whether artwork is displayed if present in the media. + * + * @param useArtwork Whether artwork is displayed. + */ + public void setUseArtwork(boolean useArtwork) { + Assertions.checkState(!useArtwork || artworkView != null); + if (this.useArtwork != useArtwork) { + this.useArtwork = useArtwork; + updateForCurrentTrackSelections(); + } } /** @@ -181,18 +365,48 @@ public final class SimpleExoPlayerView extends FrameLayout { * @param useController Whether playback controls should be enabled. */ public void setUseController(boolean useController) { + Assertions.checkState(!useController || controller != null); if (this.useController == useController) { return; } this.useController = useController; if (useController) { controller.setPlayer(player); - } else { + } else if (controller != null) { controller.hide(); controller.setPlayer(null); } } + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. Does nothing if playback controls are disabled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + return useController && controller.dispatchMediaKeyEvent(event); + } + + /** + * Shows the playback controls. Does nothing if playback controls are disabled. + */ + public void showController() { + if (useController) { + maybeShowController(true); + } + } + + /** + * Hides the playback controls. Does nothing if playback controls are disabled. + */ + public void hideController() { + if (controller != null) { + controller.hide(); + } + } + /** * Returns the playback controls timeout. The playback controls are automatically hidden after * this duration of time has elapsed without user input and with playback or buffering in @@ -213,6 +427,7 @@ public final class SimpleExoPlayerView extends FrameLayout { * the controller to remain visible indefinitely. */ public void setControllerShowTimeoutMs(int controllerShowTimeoutMs) { + Assertions.checkState(controller != null); this.controllerShowTimeoutMs = controllerShowTimeoutMs; } @@ -222,15 +437,28 @@ public final class SimpleExoPlayerView extends FrameLayout { * @param listener The listener to be notified about visibility changes. */ public void setControllerVisibilityListener(PlaybackControlView.VisibilityListener listener) { + Assertions.checkState(controller != null); controller.setVisibilityListener(listener); } + /** + * Sets the {@link SeekDispatcher}. + * + * @param seekDispatcher The {@link SeekDispatcher}, or null to use + * {@link PlaybackControlView#DEFAULT_SEEK_DISPATCHER}. + */ + public void setSeekDispatcher(SeekDispatcher seekDispatcher) { + Assertions.checkState(controller != null); + controller.setSeekDispatcher(seekDispatcher); + } + /** * Sets the rewind increment in milliseconds. * * @param rewindMs The rewind increment in milliseconds. */ public void setRewindIncrementMs(int rewindMs) { + Assertions.checkState(controller != null); controller.setRewindIncrementMs(rewindMs); } @@ -240,19 +468,41 @@ public final class SimpleExoPlayerView extends FrameLayout { * @param fastForwardMs The fast forward increment in milliseconds. */ public void setFastForwardIncrementMs(int fastForwardMs) { + Assertions.checkState(controller != null); controller.setFastForwardIncrementMs(fastForwardMs); } /** - * Get the view onto which video is rendered. This is either a {@link SurfaceView} (default) + * Gets the view onto which video is rendered. This is either a {@link SurfaceView} (default) * or a {@link TextureView} if the {@code use_texture_view} view attribute has been set to true. * - * @return either a {@link SurfaceView} or a {@link TextureView}. + * @return Either a {@link SurfaceView} or a {@link TextureView}. */ public View getVideoSurfaceView() { return surfaceView; } + /** + * Gets the overlay {@link FrameLayout}, which can be populated with UI elements to show on top of + * the player. + * + * @return The overlay {@link FrameLayout}, or {@code null} if the layout has been customized and + * the overlay is not present. + */ + public FrameLayout getOverlayFrameLayout() { + return overlayFrameLayout; + } + + /** + * Gets the {@link SubtitleView}. + * + * @return The {@link SubtitleView}, or {@code null} if the layout has been customized and the + * subtitle view is not present. + */ + public SubtitleView getSubtitleView() { + return subtitleView; + } + @Override public boolean onTouchEvent(MotionEvent ev) { if (!useController || player == null || ev.getActionMasked() != MotionEvent.ACTION_DOWN) { @@ -275,11 +525,6 @@ public final class SimpleExoPlayerView extends FrameLayout { return true; } - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - return useController ? controller.dispatchKeyEvent(event) : super.dispatchKeyEvent(event); - } - private void maybeShowController(boolean isForced) { if (!useController || player == null) { return; @@ -294,6 +539,76 @@ public final class SimpleExoPlayerView extends FrameLayout { } } + private void updateForCurrentTrackSelections() { + if (player == null) { + return; + } + TrackSelectionArray selections = player.getCurrentTrackSelections(); + for (int i = 0; i < selections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_VIDEO && selections.get(i) != null) { + // Video enabled so artwork must be hidden. If the shutter is closed, it will be opened in + // onRenderedFirstFrame(). + hideArtwork(); + return; + } + } + // Video disabled so the shutter must be closed. + if (shutterView != null) { + shutterView.setVisibility(VISIBLE); + } + // Display artwork if enabled and available, else hide it. + if (useArtwork) { + for (int i = 0; i < selections.length; i++) { + TrackSelection selection = selections.get(i); + if (selection != null) { + for (int j = 0; j < selection.length(); j++) { + Metadata metadata = selection.getFormat(j).metadata; + if (metadata != null && setArtworkFromMetadata(metadata)) { + return; + } + } + } + } + } + // Artwork disabled or unavailable. + hideArtwork(); + } + + private boolean setArtworkFromMetadata(Metadata metadata) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry metadataEntry = metadata.get(i); + if (metadataEntry instanceof ApicFrame) { + byte[] bitmapData = ((ApicFrame) metadataEntry).pictureData; + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length); + if (bitmap != null) { + int bitmapWidth = bitmap.getWidth(); + int bitmapHeight = bitmap.getHeight(); + if (bitmapWidth > 0 && bitmapHeight > 0) { + if (contentFrame != null) { + contentFrame.setAspectRatio((float) bitmapWidth / bitmapHeight); + } + artworkView.setImageBitmap(bitmap); + artworkView.setVisibility(VISIBLE); + return true; + } + } + } + } + return false; + } + + private void hideArtwork() { + if (artworkView != null) { + artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference. + artworkView.setVisibility(INVISIBLE); + } + } + + @SuppressWarnings("ResourceType") + private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) { + aspectRatioFrame.setResizeMode(resizeMode); + } + private final class ComponentListener implements SimpleExoPlayer.VideoListener, TextRenderer.Output, ExoPlayer.EventListener { @@ -301,7 +616,9 @@ public final class SimpleExoPlayerView extends FrameLayout { @Override public void onCues(List cues) { - subtitleLayout.onCues(cues); + if (subtitleView != null) { + subtitleView.onCues(cues); + } } // SimpleExoPlayer.VideoListener implementation @@ -309,17 +626,22 @@ public final class SimpleExoPlayerView extends FrameLayout { @Override public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - layout.setAspectRatio(height == 0 ? 1 : (width * pixelWidthHeightRatio) / height); + if (contentFrame != null) { + float aspectRatio = height == 0 ? 1 : (width * pixelWidthHeightRatio) / height; + contentFrame.setAspectRatio(aspectRatio); + } } @Override public void onRenderedFirstFrame() { - shutterView.setVisibility(GONE); + if (shutterView != null) { + shutterView.setVisibility(INVISIBLE); + } } @Override - public void onVideoTracksDisabled() { - shutterView.setVisibility(VISIBLE); + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + updateForCurrentTrackSelections(); } // ExoPlayer.EventListener implementation diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index 8c3ac77cb2..de461ecf0d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -252,7 +252,7 @@ import com.google.android.exoplayer2.util.Util; if (cueLine >= 0) { anchorPosition = Math.round(cueLine * firstLineHeight) + parentTop; } else { - anchorPosition = Math.round(cueLine * firstLineHeight) + parentBottom; + anchorPosition = Math.round((cueLine + 1) * firstLineHeight) + parentBottom; } } textTop = cueLineAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textHeight diff --git a/library/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index 0c8d9ef92e..49516ab6f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -122,10 +122,10 @@ public final class SubtitleView extends View implements TextRenderer.Output { /** * Sets the text size to one derived from {@link CaptioningManager#getFontScale()}, or to a - * default size on API level 19 and earlier. + * default size before API level 19. */ public void setUserDefaultTextSize() { - float fontScale = Util.SDK_INT >= 19 ? getUserCaptionFontScaleV19() : 1f; + float fontScale = Util.SDK_INT >= 19 && !isInEditMode() ? getUserCaptionFontScaleV19() : 1f; setFractionalTextSize(DEFAULT_TEXT_SIZE_FRACTION * fontScale); } @@ -180,10 +180,11 @@ public final class SubtitleView extends View implements TextRenderer.Output { /** * Sets the caption style to be equivalent to the one returned by - * {@link CaptioningManager#getUserStyle()}, or to a default style on API level 19 and earlier. + * {@link CaptioningManager#getUserStyle()}, or to a default style before API level 19. */ public void setUserDefaultStyle() { - setStyle(Util.SDK_INT >= 19 ? getUserCaptionStyleV19() : CaptionStyleCompat.DEFAULT); + setStyle(Util.SDK_INT >= 19 && !isInEditMode() + ? getUserCaptionStyleV19() : CaptionStyleCompat.DEFAULT); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSourceInputStream.java b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSourceInputStream.java index ae591d889b..c4296bd6f6 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSourceInputStream.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSourceInputStream.java @@ -67,20 +67,12 @@ public final class DataSourceInputStream extends InputStream { @Override public int read() throws IOException { int length = read(singleByteArray); - if (length == -1) { - return -1; - } - totalBytesRead++; - return singleByteArray[0] & 0xFF; + return length == -1 ? -1 : (singleByteArray[0] & 0xFF); } @Override public int read(byte[] buffer) throws IOException { - int bytesRead = read(buffer, 0, buffer.length); - if (bytesRead != -1) { - totalBytesRead += bytesRead; - } - return bytesRead; + return read(buffer, 0, buffer.length); } @Override @@ -96,15 +88,6 @@ public final class DataSourceInputStream extends InputStream { } } - @Override - public long skip(long byteCount) throws IOException { - Assertions.checkState(!closed); - checkOpened(); - long bytesSkipped = super.skip(byteCount); - totalBytesRead += bytesSkipped; - return bytesSkipped; - } - @Override public void close() throws IOException { if (!closed) { diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java b/library/src/main/java/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java index e5d62378b3..4f9e9fa5e6 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java @@ -43,4 +43,21 @@ public interface LoaderErrorThrower { */ void maybeThrowError(int minRetryCount) throws IOException; + /** + * A {@link LoaderErrorThrower} that never throws. + */ + final class Dummy implements LoaderErrorThrower { + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public void maybeThrowError(int minRetryCount) throws IOException { + // Do nothing. + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java b/library/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java index 9059f3817f..c23b609704 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java @@ -113,8 +113,8 @@ public final class ParsingLoadable implements Loadable { inputStream.open(); result = parser.parse(dataSource.getUri(), inputStream); } finally { - inputStream.close(); bytesLoaded = inputStream.bytesRead(); + inputStream.close(); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java index 3a9ea0cff0..0b7b85b8c3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java @@ -54,7 +54,7 @@ public final class RawResourceDataSource implements DataSource { * @param rawResourceId A raw resource identifier (i.e. a constant defined in {@code R.raw}). * @return The corresponding {@link Uri}. */ - public static final Uri buildRawResourceUri(int rawResourceId) { + public static Uri buildRawResourceUri(int rawResourceId) { return Uri.parse(RAW_RESOURCE_SCHEME + ":///" + rawResourceId); } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index a8a8de4361..8dcfe75670 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.cache; import java.io.File; +import java.io.IOException; import java.util.NavigableSet; import java.util.Set; @@ -60,6 +61,21 @@ public interface Cache { void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan); } + + /** + * Thrown when an error is encountered when writing data. + */ + class CacheException extends IOException { + + public CacheException(String message) { + super(message); + } + + public CacheException(IOException cause) { + super(cause); + } + + } /** * Registers a listener to listen for changes to a given key. @@ -125,7 +141,7 @@ public interface Cache { * @return The {@link CacheSpan}. * @throws InterruptedException */ - CacheSpan startReadWrite(String key, long position) throws InterruptedException; + CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException; /** * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then @@ -135,7 +151,7 @@ public interface Cache { * @param position The position of the data being requested. * @return The {@link CacheSpan}. Or null if the cache entry is locked. */ - CacheSpan startReadWriteNonBlocking(String key, long position); + CacheSpan startReadWriteNonBlocking(String key, long position) throws CacheException; /** * Obtains a cache file into which data can be written. Must only be called when holding a @@ -147,7 +163,7 @@ public interface Cache { * is enough space in the cache. * @return The file into which data should be written. */ - File startFile(String key, long position, long maxLength); + File startFile(String key, long position, long maxLength) throws CacheException; /** * Commits a file into the cache. Must only be called when holding a corresponding hole @@ -155,7 +171,7 @@ public interface Cache { * * @param file A newly written cache file. */ - void commitFile(File file); + void commitFile(File file) throws CacheException; /** * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which @@ -170,7 +186,7 @@ public interface Cache { * * @param span The {@link CacheSpan} to remove. */ - void removeSpan(CacheSpan span); + void removeSpan(CacheSpan span) throws CacheException; /** * Queries if a range is entirely available in the cache. @@ -187,10 +203,8 @@ public interface Cache { * * @param key The cache key for the data. * @param length The length of the data. - * @return Whether the length was set successfully. Returns false if the length conflicts with the - * existing contents of the cache. */ - boolean setContentLength(String key, long length); + void setContentLength(String key, long length) throws CacheException; /** * Returns the content length for the given key if one set, or {@link diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java index 96c198b4c9..d57f3ee140 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -18,12 +18,14 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSink; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.ReusableBufferedOutputStream; import com.google.android.exoplayer2.util.Util; import java.io.File; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; /** * Writes data into a cache. @@ -32,17 +34,20 @@ public final class CacheDataSink implements DataSink { private final Cache cache; private final long maxCacheFileSize; + private final int bufferSize; private DataSpec dataSpec; private File file; - private FileOutputStream outputStream; + private OutputStream outputStream; + private FileOutputStream underlyingFileOutputStream; private long outputStreamBytesWritten; private long dataSpecBytesWritten; + private ReusableBufferedOutputStream bufferedOutputStream; /** * Thrown when IOException is encountered when writing data into sink. */ - public static class CacheDataSinkException extends IOException { + public static class CacheDataSinkException extends CacheException { public CacheDataSinkException(IOException cause) { super(cause); @@ -50,7 +55,6 @@ public final class CacheDataSink implements DataSink { } - /** * @param cache The cache into which data should be written. * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for @@ -58,8 +62,21 @@ public final class CacheDataSink implements DataSink { * multiple cache files. */ public CacheDataSink(Cache cache, long maxCacheFileSize) { + this(cache, maxCacheFileSize, 0); + } + + /** + * @param cache The cache into which data should be written. + * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for + * a {@link DataSpec} whose size exceeds this value, then the data will be fragmented into + * multiple cache files. + * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative + * value disables buffering. + */ + public CacheDataSink(Cache cache, long maxCacheFileSize, int bufferSize) { this.cache = Assertions.checkNotNull(cache); this.maxCacheFileSize = maxCacheFileSize; + this.bufferSize = bufferSize; } @Override @@ -71,7 +88,7 @@ public final class CacheDataSink implements DataSink { dataSpecBytesWritten = 0; try { openNextOutputStream(); - } catch (FileNotFoundException e) { + } catch (IOException e) { throw new CacheDataSinkException(e); } } @@ -112,13 +129,25 @@ public final class CacheDataSink implements DataSink { } } - private void openNextOutputStream() throws FileNotFoundException { + private void openNextOutputStream() throws IOException { file = cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, Math.min(dataSpec.length - dataSpecBytesWritten, maxCacheFileSize)); - outputStream = new FileOutputStream(file); + underlyingFileOutputStream = new FileOutputStream(file); + if (bufferSize > 0) { + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream, + bufferSize); + } else { + bufferedOutputStream.reset(underlyingFileOutputStream); + } + outputStream = bufferedOutputStream; + } else { + outputStream = underlyingFileOutputStream; + } outputStreamBytesWritten = 0; } + @SuppressWarnings("ThrowFromFinallyBlock") private void closeCurrentOutputStream() throws IOException { if (outputStream == null) { return; @@ -127,17 +156,18 @@ public final class CacheDataSink implements DataSink { boolean success = false; try { outputStream.flush(); - outputStream.getFD().sync(); + underlyingFileOutputStream.getFD().sync(); success = true; } finally { Util.closeQuietly(outputStream); - if (success) { - cache.commitFile(file); - } else { - file.delete(); - } outputStream = null; + File fileToCommit = file; file = null; + if (success) { + cache.commitFile(fileToCommit); + } else { + fileToCommit.delete(); + } } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java index 95def56b8e..0c8c006e2c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 The Android Open Sink Project + * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index 727eb068ce..4dc5431b47 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.upstream.cache; import android.net.Uri; import android.support.annotation.IntDef; -import android.util.Log; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSink; import com.google.android.exoplayer2.upstream.DataSource; @@ -25,7 +24,7 @@ import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TeeDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink.CacheDataSinkException; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.io.IOException; import java.io.InterruptedIOException; import java.lang.annotation.Retention; @@ -51,7 +50,7 @@ public final class CacheDataSource implements DataSource { */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = {FLAG_BLOCK_ON_CACHE, FLAG_IGNORE_CACHE_ON_ERROR, - FLAG_CACHE_UNBOUNDED_REQUESTS}) + FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}) public @interface Flags {} /** * A flag indicating whether we will block reads if the cache key is locked. If this flag is @@ -67,11 +66,9 @@ public final class CacheDataSource implements DataSource { public static final int FLAG_IGNORE_CACHE_ON_ERROR = 1 << 1; /** - * A flag indicating whether the response is cached if the range of the request is unbounded. - * Disabled by default because, as a side effect, this may allow streams with every chunk from a - * separate URL cached which is broken currently. + * A flag indicating that the cache should be bypassed for requests whose lengths are unset. */ - public static final int FLAG_CACHE_UNBOUNDED_REQUESTS = 1 << 2; + public static final int FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS = 1 << 2; /** * Listener of {@link CacheDataSource} events. @@ -88,8 +85,6 @@ public final class CacheDataSource implements DataSource { } - private static final String TAG = "CacheDataSource"; - private final Cache cache; private final DataSource cacheReadDataSource; private final DataSource cacheWriteDataSource; @@ -98,7 +93,7 @@ public final class CacheDataSource implements DataSource { private final boolean blockOnCache; private final boolean ignoreCacheOnError; - private final boolean bypassUnboundedRequests; + private final boolean ignoreCacheForUnsetLengthRequests; private DataSource currentDataSource; private boolean currentRequestUnbounded; @@ -127,8 +122,8 @@ public final class CacheDataSource implements DataSource { * * @param cache The cache. * @param upstream A {@link DataSource} for reading data not in the cache. - * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} - * and {@link #FLAG_CACHE_UNBOUNDED_REQUESTS} or 0. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE} and {@link + * #FLAG_IGNORE_CACHE_ON_ERROR} or 0. * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the cached data size * exceeds this value, then the data will be fragmented into multiple cache files. The * finer-grained this is the finer-grained the eviction policy can be. @@ -148,8 +143,8 @@ public final class CacheDataSource implements DataSource { * @param upstream A {@link DataSource} for reading data not in the cache. * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. - * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} - * and {@link #FLAG_CACHE_UNBOUNDED_REQUESTS} or 0. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE} and {@link + * #FLAG_IGNORE_CACHE_ON_ERROR} or 0. * @param eventListener An optional {@link EventListener} to receive events. */ public CacheDataSource(Cache cache, DataSource upstream, DataSource cacheReadDataSource, @@ -158,7 +153,8 @@ public final class CacheDataSource implements DataSource { this.cacheReadDataSource = cacheReadDataSource; this.blockOnCache = (flags & FLAG_BLOCK_ON_CACHE) != 0; this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0; - this.bypassUnboundedRequests = (flags & FLAG_CACHE_UNBOUNDED_REQUESTS) == 0; + this.ignoreCacheForUnsetLengthRequests = + (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0; this.upstreamDataSource = upstream; if (cacheWriteDataSink != null) { this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink); @@ -173,10 +169,10 @@ public final class CacheDataSource implements DataSource { try { uri = dataSpec.uri; flags = dataSpec.flags; - key = dataSpec.key; + key = dataSpec.key != null ? dataSpec.key : uri.toString(); readPosition = dataSpec.position; currentRequestIgnoresCache = (ignoreCacheOnError && seenCacheError) - || (bypassUnboundedRequests && dataSpec.length == C.LENGTH_UNSET); + || (dataSpec.length == C.LENGTH_UNSET && ignoreCacheForUnsetLengthRequests); if (dataSpec.length != C.LENGTH_UNSET || currentRequestIgnoresCache) { bytesRemaining = dataSpec.length; } else { @@ -342,11 +338,8 @@ public final class CacheDataSource implements DataSource { return successful; } - private void setContentLength(long length) { - if (!cache.setContentLength(key, length)) { - Log.e(TAG, "cache.setContentLength(" + length + ") failed. cache.getContentLength() = " - + cache.getContentLength(key)); - } + private void setContentLength(long length) throws IOException { + cache.setContentLength(key, length); } private void closeCurrentSource() throws IOException { @@ -366,7 +359,7 @@ public final class CacheDataSource implements DataSource { } private void handleBeforeThrow(IOException exception) { - if (currentDataSource == cacheReadDataSource || exception instanceof CacheDataSinkException) { + if (currentDataSource == cacheReadDataSource || exception instanceof CacheException) { seenCacheError = true; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java index 627bb7e2f4..8944b45033 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java @@ -22,7 +22,7 @@ package com.google.android.exoplayer2.upstream.cache; public interface CacheEvictor extends Cache.Listener { /** - * Called when cache has beeen initialized. + * Called when cache has been initialized. */ void onCacheInitialized(); diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java index d706f4f006..fb96c0fb0e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java @@ -16,21 +16,12 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.util.Util; import java.io.File; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Defines a span of data that may or may not be cached (as indicated by {@link #isCached}). */ -public final class CacheSpan implements Comparable { - - private static final String SUFFIX = ".v2.exo"; - private static final Pattern CACHE_FILE_PATTERN_V1 = - Pattern.compile("^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$", Pattern.DOTALL); - private static final Pattern CACHE_FILE_PATTERN_V2 = - Pattern.compile("^(.+)\\.(\\d+)\\.(\\d+)\\.v2\\.exo$", Pattern.DOTALL); +public class CacheSpan implements Comparable { /** * The cache key that uniquely identifies the original stream. @@ -57,64 +48,34 @@ public final class CacheSpan implements Comparable { */ public final long lastAccessTimestamp; - public static File getCacheFileName(File cacheDir, String key, long offset, - long lastAccessTimestamp) { - return new File(cacheDir, - Util.escapeFileName(key) + "." + offset + "." + lastAccessTimestamp + SUFFIX); - } - - public static CacheSpan createLookup(String key, long position) { - return new CacheSpan(key, position, C.LENGTH_UNSET, false, C.TIME_UNSET, null); - } - - public static CacheSpan createOpenHole(String key, long position) { - return new CacheSpan(key, position, C.LENGTH_UNSET, false, C.TIME_UNSET, null); - } - - public static CacheSpan createClosedHole(String key, long position, long length) { - return new CacheSpan(key, position, length, false, C.TIME_UNSET, null); + /** + * Creates a hole CacheSpan which isn't cached, has no last access time and no file associated. + * + * @param key The cache key that uniquely identifies the original stream. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an + * open-ended hole. + */ + public CacheSpan(String key, long position, long length) { + this(key, position, length, C.TIME_UNSET, null); } /** - * Creates a cache span from an underlying cache file. + * Creates a CacheSpan. * - * @param file The cache file. - * @return The span, or null if the file name is not correctly formatted. + * @param key The cache key that uniquely identifies the original stream. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an + * open-ended hole. + * @param lastAccessTimestamp The last access timestamp, or {@link C#TIME_UNSET} if + * {@link #isCached} is false. + * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole. */ - public static CacheSpan createCacheEntry(File file) { - Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(file.getName()); - if (!matcher.matches()) { - return null; - } - String key = Util.unescapeFileName(matcher.group(1)); - return key == null ? null : createCacheEntry( - key, Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3)), file); - } - - static File upgradeIfNeeded(File file) { - Matcher matcher = CACHE_FILE_PATTERN_V1.matcher(file.getName()); - if (!matcher.matches()) { - return file; - } - String key = matcher.group(1); // Keys were not escaped in version 1. - File newCacheFile = getCacheFileName(file.getParentFile(), key, - Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3))); - file.renameTo(newCacheFile); - return newCacheFile; - } - - private static CacheSpan createCacheEntry(String key, long position, long lastAccessTimestamp, - File file) { - return new CacheSpan(key, position, file.length(), true, lastAccessTimestamp, file); - } - - // Visible for testing. - CacheSpan(String key, long position, long length, boolean isCached, - long lastAccessTimestamp, File file) { + public CacheSpan(String key, long position, long length, long lastAccessTimestamp, File file) { this.key = key; this.position = position; this.length = length; - this.isCached = isCached; + this.isCached = file != null; this.file = file; this.lastAccessTimestamp = lastAccessTimestamp; } @@ -127,15 +88,10 @@ public final class CacheSpan implements Comparable { } /** - * Renames the file underlying this cache span to update its last access time. - * - * @return A {@link CacheSpan} representing the updated cache file. + * Returns whether this is a hole {@link CacheSpan}. */ - public CacheSpan touch() { - long now = System.currentTimeMillis(); - File newCacheFile = getCacheFileName(file.getParentFile(), key, position, now); - file.renameTo(newCacheFile); - return createCacheEntry(key, position, now, newCacheFile); + public boolean isHoleSpan() { + return !isCached; } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java new file mode 100644 index 0000000000..c744a176ad --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.util.Assertions; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.TreeSet; + +/** + * Defines the cached content for a single stream. + */ +/*package*/ final class CachedContent { + + /** + * The cache file id that uniquely identifies the original stream. + */ + public final int id; + /** + * The cache key that uniquely identifies the original stream. + */ + public final String key; + /** + * The cached spans of this content. + */ + private final TreeSet cachedSpans; + /** + * The length of the original stream, or {@link C#LENGTH_UNSET} if the length is unknown. + */ + private long length; + + /** + * Reads an instance from a {@link DataInputStream}. + * + * @param input Input stream containing values needed to initialize CachedContent instance. + * @throws IOException If an error occurs during reading values. + */ + public CachedContent(DataInputStream input) throws IOException { + this(input.readInt(), input.readUTF(), input.readLong()); + } + + /** + * Creates a CachedContent. + * + * @param id The cache file id. + * @param key The cache stream key. + * @param length The length of the original stream. + */ + public CachedContent(int id, String key, long length) { + this.id = id; + this.key = key; + this.length = length; + this.cachedSpans = new TreeSet<>(); + } + + /** + * Writes the instance to a {@link DataOutputStream}. + * + * @param output Output stream to store the values. + * @throws IOException If an error occurs during writing values to output. + */ + public void writeToStream(DataOutputStream output) throws IOException { + output.writeInt(id); + output.writeUTF(key); + output.writeLong(length); + } + + /** Returns the length of the content. */ + public long getLength() { + return length; + } + + /** Sets the length of the content. */ + public void setLength(long length) { + this.length = length; + } + + /** Adds the given {@link SimpleCacheSpan} which contains a part of the content. */ + public void addSpan(SimpleCacheSpan span) { + cachedSpans.add(span); + } + + /** Returns a set of all {@link SimpleCacheSpan}s. */ + public TreeSet getSpans() { + return cachedSpans; + } + + /** + * Returns the span containing the position. If there isn't one, it returns a hole span + * which defines the maximum extents of the hole in the cache. + */ + public SimpleCacheSpan getSpan(long position) { + SimpleCacheSpan span = getSpanInternal(position); + if (!span.isCached) { + SimpleCacheSpan ceilEntry = cachedSpans.ceiling(span); + return ceilEntry == null ? SimpleCacheSpan.createOpenHole(key, position) + : SimpleCacheSpan.createClosedHole(key, position, ceilEntry.position - position); + } + return span; + } + + /** Queries if a range is entirely available in the cache. */ + public boolean isCached(long position, long length) { + SimpleCacheSpan floorSpan = getSpanInternal(position); + if (!floorSpan.isCached) { + // We don't have a span covering the start of the queried region. + return false; + } + long queryEndPosition = position + length; + long currentEndPosition = floorSpan.position + floorSpan.length; + if (currentEndPosition >= queryEndPosition) { + // floorSpan covers the queried region. + return true; + } + for (SimpleCacheSpan next : cachedSpans.tailSet(floorSpan, false)) { + if (next.position > currentEndPosition) { + // There's a hole in the cache within the queried region. + return false; + } + // We expect currentEndPosition to always equal (next.position + next.length), but + // perform a max check anyway to guard against the existence of overlapping spans. + currentEndPosition = Math.max(currentEndPosition, next.position + next.length); + if (currentEndPosition >= queryEndPosition) { + // We've found spans covering the queried region. + return true; + } + } + // We ran out of spans before covering the queried region. + return false; + } + + /** + * Copies the given span with an updated last access time. Passed span becomes invalid after this + * call. + * + * @param cacheSpan Span to be copied and updated. + * @return a span with the updated last access time. + * @throws CacheException If renaming of the underlying span file failed. + */ + public SimpleCacheSpan touch(SimpleCacheSpan cacheSpan) throws CacheException { + // Remove the old span from the in-memory representation. + Assertions.checkState(cachedSpans.remove(cacheSpan)); + // Obtain a new span with updated last access timestamp. + SimpleCacheSpan newCacheSpan = cacheSpan.copyWithUpdatedLastAccessTime(id); + // Rename the cache file + if (!cacheSpan.file.renameTo(newCacheSpan.file)) { + throw new CacheException("Renaming of " + cacheSpan.file + " to " + newCacheSpan.file + + " failed."); + } + // Add the updated span back into the in-memory representation. + cachedSpans.add(newCacheSpan); + return newCacheSpan; + } + + /** Returns whether there are any spans cached. */ + public boolean isEmpty() { + return cachedSpans.isEmpty(); + } + + /** Removes the given span from cache. */ + public boolean removeSpan(CacheSpan span) { + if (cachedSpans.remove(span)) { + span.file.delete(); + return true; + } + return false; + } + + /** Calculates a hash code for the header of this {@code CachedContent}. */ + public int headerHashCode() { + int result = id; + result = 31 * result + key.hashCode(); + result = 31 * result + (int) (length ^ (length >>> 32)); + return result; + } + + /** + * Returns the span containing the position. If there isn't one, it returns the lookup span it + * used for searching. + */ + private SimpleCacheSpan getSpanInternal(long position) { + SimpleCacheSpan lookupSpan = SimpleCacheSpan.createLookup(key, position); + SimpleCacheSpan floorSpan = cachedSpans.floor(lookupSpan); + return floorSpan == null || floorSpan.position + floorSpan.length <= position ? lookupSpan + : floorSpan; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java new file mode 100644 index 0000000000..9e38dabc31 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import android.util.Log; +import android.util.SparseArray; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.AtomicFile; +import com.google.android.exoplayer2.util.ReusableBufferedOutputStream; +import com.google.android.exoplayer2.util.Util; +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Random; +import java.util.Set; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * This class maintains the index of cached content. + */ +/*package*/ final class CachedContentIndex { + + public static final String FILE_NAME = "cached_content_index.exi"; + + private static final int VERSION = 1; + + private static final int FLAG_ENCRYPTED_INDEX = 1; + + private static final String TAG = "CachedContentIndex"; + + private final HashMap keyToContent; + private final SparseArray idToKey; + private final AtomicFile atomicFile; + private final Cipher cipher; + private final SecretKeySpec secretKeySpec; + private boolean changed; + private ReusableBufferedOutputStream bufferedOutputStream; + + /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ + public CachedContentIndex(File cacheDir) { + this(cacheDir, null); + } + + /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ + public CachedContentIndex(File cacheDir, byte[] secretKey) { + if (secretKey != null) { + try { + cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); + secretKeySpec = new SecretKeySpec(secretKey, "AES"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new IllegalStateException(e); // Should never happen. + } + } else { + cipher = null; + secretKeySpec = null; + } + keyToContent = new HashMap<>(); + idToKey = new SparseArray<>(); + atomicFile = new AtomicFile(new File(cacheDir, FILE_NAME)); + } + + /** Loads the index file. */ + public void load() { + Assertions.checkState(!changed); + if (!readFile()) { + atomicFile.delete(); + keyToContent.clear(); + idToKey.clear(); + } + } + + /** Stores the index data to index file if there is a change. */ + public void store() throws CacheException { + if (!changed) { + return; + } + writeFile(); + changed = false; + } + + /** + * Adds the given key to the index if it isn't there already. + * + * @param key The cache key that uniquely identifies the original stream. + * @return A new or existing CachedContent instance with the given key. + */ + public CachedContent add(String key) { + CachedContent cachedContent = keyToContent.get(key); + if (cachedContent == null) { + cachedContent = addNew(key, C.LENGTH_UNSET); + } + return cachedContent; + } + + /** Returns a CachedContent instance with the given key or null if there isn't one. */ + public CachedContent get(String key) { + return keyToContent.get(key); + } + + /** + * Returns a Collection of all CachedContent instances in the index. The collection is backed by + * the {@code keyToContent} map, so changes to the map are reflected in the collection, and + * vice-versa. If the map is modified while an iteration over the collection is in progress + * (except through the iterator's own remove operation), the results of the iteration are + * undefined. + */ + public Collection getAll() { + return keyToContent.values(); + } + + /** Returns an existing or new id assigned to the given key. */ + public int assignIdForKey(String key) { + return add(key).id; + } + + /** Returns the key which has the given id assigned. */ + public String getKeyForId(int id) { + return idToKey.get(id); + } + + /** + * Removes {@link CachedContent} with the given key from index. It shouldn't contain any spans. + * + * @throws IllegalStateException If {@link CachedContent} isn't empty. + */ + public void removeEmpty(String key) { + CachedContent cachedContent = keyToContent.remove(key); + if (cachedContent != null) { + Assertions.checkState(cachedContent.isEmpty()); + idToKey.remove(cachedContent.id); + changed = true; + } + } + + /** Removes empty {@link CachedContent} instances from index. */ + public void removeEmpty() { + LinkedList cachedContentToBeRemoved = new LinkedList<>(); + for (CachedContent cachedContent : keyToContent.values()) { + if (cachedContent.isEmpty()) { + cachedContentToBeRemoved.add(cachedContent.key); + } + } + for (String key : cachedContentToBeRemoved) { + removeEmpty(key); + } + } + + /** + * Returns a set of all content keys. The set is backed by the {@code keyToContent} map, so + * changes to the map are reflected in the set, and vice-versa. If the map is modified while an + * iteration over the set is in progress (except through the iterator's own remove operation), the + * results of the iteration are undefined. + */ + public Set getKeys() { + return keyToContent.keySet(); + } + + /** + * Sets the content length for the given key. A new {@link CachedContent} is added if there isn't + * one already with the given key. + */ + public void setContentLength(String key, long length) { + CachedContent cachedContent = get(key); + if (cachedContent != null) { + if (cachedContent.getLength() != length) { + cachedContent.setLength(length); + changed = true; + } + } else { + addNew(key, length); + } + } + + /** + * Returns the content length for the given key if one set, or {@link + * com.google.android.exoplayer2.C#LENGTH_UNSET} otherwise. + */ + public long getContentLength(String key) { + CachedContent cachedContent = get(key); + return cachedContent == null ? C.LENGTH_UNSET : cachedContent.getLength(); + } + + private boolean readFile() { + DataInputStream input = null; + try { + InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); + input = new DataInputStream(inputStream); + int version = input.readInt(); + if (version != VERSION) { + // Currently there is no other version + return false; + } + + int flags = input.readInt(); + if ((flags & FLAG_ENCRYPTED_INDEX) != 0) { + if (cipher == null) { + return false; + } + byte[] initializationVector = new byte[16]; + input.readFully(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + input = new DataInputStream(new CipherInputStream(inputStream, cipher)); + } else { + if (cipher != null) { + changed = true; // Force index to be rewritten encrypted after read. + } + } + + int count = input.readInt(); + int hashCode = 0; + for (int i = 0; i < count; i++) { + CachedContent cachedContent = new CachedContent(input); + add(cachedContent); + hashCode += cachedContent.headerHashCode(); + } + if (input.readInt() != hashCode) { + return false; + } + } catch (FileNotFoundException e) { + return false; + } catch (IOException e) { + Log.e(TAG, "Error reading cache content index file.", e); + return false; + } finally { + if (input != null) { + Util.closeQuietly(input); + } + } + return true; + } + + private void writeFile() throws CacheException { + DataOutputStream output = null; + try { + OutputStream outputStream = atomicFile.startWrite(); + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(outputStream); + } else { + bufferedOutputStream.reset(outputStream); + } + output = new DataOutputStream(bufferedOutputStream); + output.writeInt(VERSION); + + int flags = cipher != null ? FLAG_ENCRYPTED_INDEX : 0; + output.writeInt(flags); + + if (cipher != null) { + byte[] initializationVector = new byte[16]; + new Random().nextBytes(initializationVector); + output.write(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); // Should never happen. + } + output.flush(); + output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher)); + } + + output.writeInt(keyToContent.size()); + int hashCode = 0; + for (CachedContent cachedContent : keyToContent.values()) { + cachedContent.writeToStream(output); + hashCode += cachedContent.headerHashCode(); + } + output.writeInt(hashCode); + atomicFile.endWrite(output); + } catch (IOException e) { + throw new CacheException(e); + } finally { + Util.closeQuietly(output); + } + } + + private void add(CachedContent cachedContent) { + keyToContent.put(cachedContent.key, cachedContent); + idToKey.put(cachedContent.id, cachedContent.key); + } + + /** Adds the given CachedContent to the index. */ + /*package*/ void addNew(CachedContent cachedContent) { + add(cachedContent); + changed = true; + } + + private CachedContent addNew(String key, long length) { + int id = getNewId(idToKey); + CachedContent cachedContent = new CachedContent(id, key, length); + addNew(cachedContent); + return cachedContent; + } + + /** + * Returns an id which isn't used in the given array. If the maximum id in the array is smaller + * than {@link java.lang.Integer#MAX_VALUE} it just returns the next bigger integer. Otherwise it + * returns the smallest unused non-negative integer. + */ + //@VisibleForTesting + public static int getNewId(SparseArray idToKey) { + int size = idToKey.size(); + int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1); + if (id < 0) { // In case if we pass max int value. + // TODO optimization: defragmentation or binary search? + for (id = 0; id < size; id++) { + if (id != idToKey.keyAt(id)) { + break; + } + } + } + return id; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java index 791fb677f1..d2a84f65f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.cache; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.util.Comparator; import java.util.TreeSet; @@ -74,7 +75,11 @@ public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor, Compar private void evictCache(Cache cache, long requiredSpace) { while (currentSize + requiredSpace > maxBytes) { - cache.removeSpan(leastRecentlyUsed.first()); + try { + cache.removeSpan(leastRecentlyUsed.first()); + } catch (CacheException e) { + // do nothing. + } } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index a2f2596ad5..e3e887c6ed 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -16,16 +16,13 @@ package com.google.android.exoplayer2.upstream.cache; import android.os.ConditionVariable; - -import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; -import java.util.Map.Entry; +import java.util.LinkedList; import java.util.NavigableSet; import java.util.Set; import java.util.TreeSet; @@ -38,21 +35,36 @@ public final class SimpleCache implements Cache { private final File cacheDir; private final CacheEvictor evictor; private final HashMap lockedSpans; - private final HashMap>> cachedSpans; + private final CachedContentIndex index; private final HashMap> listeners; private long totalSpace = 0; + private CacheException initializationException; /** * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence * the directory cannot be used to store other files. * * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. */ public SimpleCache(File cacheDir, CacheEvictor evictor) { + this(cacheDir, evictor, null); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. + * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC. + * The key must be 16 bytes long. + */ + public SimpleCache(File cacheDir, CacheEvictor evictor, byte[] secretKey) { this.cacheDir = cacheDir; this.evictor = evictor; this.lockedSpans = new HashMap<>(); - this.cachedSpans = new HashMap<>(); + this.index = new CachedContentIndex(cacheDir, secretKey); this.listeners = new HashMap<>(); // Start cache initialization. final ConditionVariable conditionVariable = new ConditionVariable(); @@ -61,7 +73,12 @@ public final class SimpleCache implements Cache { public void run() { synchronized (SimpleCache.this) { conditionVariable.open(); - initialize(); + try { + initialize(); + } catch (CacheException e) { + initializationException = e; + } + SimpleCache.this.evictor.onCacheInitialized(); } } }.start(); @@ -92,13 +109,13 @@ public final class SimpleCache implements Cache { @Override public synchronized NavigableSet getCachedSpans(String key) { - TreeSet spansForKey = getSpansForKey(key); - return spansForKey == null ? null : new TreeSet<>(spansForKey); + CachedContent cachedContent = index.get(key); + return cachedContent == null ? null : new TreeSet(cachedContent.getSpans()); } @Override public synchronized Set getKeys() { - return new HashSet<>(cachedSpans.keySet()); + return new HashSet<>(index.getKeys()); } @Override @@ -107,11 +124,10 @@ public final class SimpleCache implements Cache { } @Override - public synchronized CacheSpan startReadWrite(String key, long position) - throws InterruptedException { - CacheSpan lookupSpan = CacheSpan.createLookup(key, position); + public synchronized SimpleCacheSpan startReadWrite(String key, long position) + throws InterruptedException, CacheException { while (true) { - CacheSpan span = startReadWriteNonBlocking(lookupSpan); + SimpleCacheSpan span = startReadWriteNonBlocking(key, position); if (span != null) { return span; } else { @@ -125,25 +141,25 @@ public final class SimpleCache implements Cache { } @Override - public synchronized CacheSpan startReadWriteNonBlocking(String key, long position) { - return startReadWriteNonBlocking(CacheSpan.createLookup(key, position)); - } + public synchronized SimpleCacheSpan startReadWriteNonBlocking(String key, long position) + throws CacheException { + if (initializationException != null) { + throw initializationException; + } - private synchronized CacheSpan startReadWriteNonBlocking(CacheSpan lookupSpan) { - CacheSpan cacheSpan = getSpan(lookupSpan); + SimpleCacheSpan cacheSpan = getSpan(key, position); // Read case. if (cacheSpan.isCached) { // Obtain a new span with updated last access timestamp. - CacheSpan newCacheSpan = cacheSpan.touch(); - replaceSpan(cacheSpan, newCacheSpan); + SimpleCacheSpan newCacheSpan = index.get(key).touch(cacheSpan); notifySpanTouched(cacheSpan, newCacheSpan); return newCacheSpan; } // Write case, lock available. - if (!lockedSpans.containsKey(lookupSpan.key)) { - lockedSpans.put(lookupSpan.key, cacheSpan); + if (!lockedSpans.containsKey(key)) { + lockedSpans.put(key, cacheSpan); return cacheSpan; } @@ -152,20 +168,22 @@ public final class SimpleCache implements Cache { } @Override - public synchronized File startFile(String key, long position, long maxLength) { + public synchronized File startFile(String key, long position, long maxLength) + throws CacheException { Assertions.checkState(lockedSpans.containsKey(key)); if (!cacheDir.exists()) { // For some reason the cache directory doesn't exist. Make a best effort to create it. - removeStaleSpans(); + removeStaleSpansAndCachedContents(); cacheDir.mkdirs(); } evictor.onStartFile(this, key, position, maxLength); - return CacheSpan.getCacheFileName(cacheDir, key, position, System.currentTimeMillis()); + return SimpleCacheSpan.getCacheFile(cacheDir, index.assignIdForKey(key), position, + System.currentTimeMillis()); } @Override - public synchronized void commitFile(File file) { - CacheSpan span = CacheSpan.createCacheEntry(file); + public synchronized void commitFile(File file) throws CacheException { + SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, index); Assertions.checkState(span != null); Assertions.checkState(lockedSpans.containsKey(span.key)); // If the file doesn't exist, don't add it to the in-memory representation. @@ -183,6 +201,7 @@ public final class SimpleCache implements Cache { Assertions.checkState((span.position + span.length) <= length); } addSpan(span); + index.store(); notifyAll(); } @@ -193,67 +212,65 @@ public final class SimpleCache implements Cache { } /** - * Returns the cache {@link CacheSpan} corresponding to the provided lookup {@link CacheSpan}. - *

    - * If the lookup position is contained by an existing entry in the cache, then the returned - * {@link CacheSpan} defines the file in which the data is stored. If the lookup position is not - * contained by an existing entry, then the returned {@link CacheSpan} defines the maximum extents - * of the hole in the cache. + * Returns the cache {@link SimpleCacheSpan} corresponding to the provided lookup {@link + * SimpleCacheSpan}. * - * @param lookupSpan A lookup {@link CacheSpan} specifying a key and position. - * @return The corresponding cache {@link CacheSpan}. + *

    If the lookup position is contained by an existing entry in the cache, then the returned + * {@link SimpleCacheSpan} defines the file in which the data is stored. If the lookup position is + * not contained by an existing entry, then the returned {@link SimpleCacheSpan} defines the + * maximum extents of the hole in the cache. + * + * @param key The key of the span being requested. + * @param position The position of the span being requested. + * @return The corresponding cache {@link SimpleCacheSpan}. */ - private CacheSpan getSpan(CacheSpan lookupSpan) { - String key = lookupSpan.key; - long offset = lookupSpan.position; - TreeSet entries = getSpansForKey(key); - if (entries == null) { - return CacheSpan.createOpenHole(key, lookupSpan.position); + private SimpleCacheSpan getSpan(String key, long position) throws CacheException { + CachedContent cachedContent = index.get(key); + if (cachedContent == null) { + return SimpleCacheSpan.createOpenHole(key, position); } - CacheSpan floorSpan = entries.floor(lookupSpan); - if (floorSpan != null && - floorSpan.position <= offset && offset < floorSpan.position + floorSpan.length) { - // The lookup position is contained within floorSpan. - if (floorSpan.file.exists()) { - return floorSpan; - } else { + while (true) { + SimpleCacheSpan span = cachedContent.getSpan(position); + if (span.isCached && !span.file.exists()) { // The file has been deleted from under us. It's likely that other files will have been // deleted too, so scan the whole in-memory representation. - removeStaleSpans(); - return getSpan(lookupSpan); + removeStaleSpansAndCachedContents(); + continue; } + return span; } - CacheSpan ceilEntry = entries.ceiling(lookupSpan); - return ceilEntry == null ? CacheSpan.createOpenHole(key, lookupSpan.position) : - CacheSpan.createClosedHole(key, lookupSpan.position, - ceilEntry.position - lookupSpan.position); } /** * Ensures that the cache's in-memory representation has been initialized. */ - private void initialize() { + private void initialize() throws CacheException { if (!cacheDir.exists()) { cacheDir.mkdirs(); + return; } + + index.load(); + File[] files = cacheDir.listFiles(); if (files == null) { return; } for (File file : files) { - if (file.length() == 0) { - file.delete(); + if (file.getName().equals(CachedContentIndex.FILE_NAME)) { + continue; + } + SimpleCacheSpan span = file.length() > 0 + ? SimpleCacheSpan.createCacheEntry(file, index) : null; + if (span != null) { + addSpan(span); } else { - file = CacheSpan.upgradeIfNeeded(file); - CacheSpan span = CacheSpan.createCacheEntry(file); - if (span == null) { - file.delete(); - } else { - addSpan(span); - } + file.delete(); } } - evictor.onCacheInitialized(); + + index.removeEmpty(); + index.store(); } /** @@ -261,59 +278,47 @@ public final class SimpleCache implements Cache { * * @param span The span to be added. */ - private void addSpan(CacheSpan span) { - Pair> entryForKey = cachedSpans.get(span.key); - TreeSet spansForKey; - if (entryForKey == null) { - spansForKey = new TreeSet<>(); - setKeyValue(span.key, C.LENGTH_UNSET, spansForKey); - } else { - spansForKey = entryForKey.second; - } - spansForKey.add(span); + private void addSpan(SimpleCacheSpan span) { + index.add(span.key).addSpan(span); totalSpace += span.length; notifySpanAdded(span); } - @Override - public synchronized void removeSpan(CacheSpan span) { - TreeSet spansForKey = getSpansForKey(span.key); + private void removeSpan(CacheSpan span, boolean removeEmptyCachedContent) throws CacheException { + CachedContent cachedContent = index.get(span.key); + Assertions.checkState(cachedContent.removeSpan(span)); totalSpace -= span.length; - Assertions.checkState(spansForKey.remove(span)); - span.file.delete(); - if (spansForKey.isEmpty()) { - cachedSpans.remove(span.key); + if (removeEmptyCachedContent && cachedContent.isEmpty()) { + index.removeEmpty(cachedContent.key); + index.store(); } notifySpanRemoved(span); } + @Override + public synchronized void removeSpan(CacheSpan span) throws CacheException { + removeSpan(span, true); + } + /** * Scans all of the cached spans in the in-memory representation, removing any for which files * no longer exist. */ - private void removeStaleSpans() { - Iterator>>> iterator = - cachedSpans.entrySet().iterator(); - while (iterator.hasNext()) { - Entry>> next = iterator.next(); - Iterator spanIterator = next.getValue().second.iterator(); - boolean isEmpty = true; - while (spanIterator.hasNext()) { - CacheSpan span = spanIterator.next(); + private void removeStaleSpansAndCachedContents() throws CacheException { + LinkedList spansToBeRemoved = new LinkedList<>(); + for (CachedContent cachedContent : index.getAll()) { + for (CacheSpan span : cachedContent.getSpans()) { if (!span.file.exists()) { - spanIterator.remove(); - if (span.isCached) { - totalSpace -= span.length; - } - notifySpanRemoved(span); - } else { - isEmpty = false; + spansToBeRemoved.add(span); } } - if (isEmpty) { - iterator.remove(); - } } + for (CacheSpan span : spansToBeRemoved) { + // Remove span but not CachedContent to prevent multiple index.store() calls. + removeSpan(span, false); + } + index.removeEmpty(); + index.store(); } private void notifySpanRemoved(CacheSpan span) { @@ -326,7 +331,7 @@ public final class SimpleCache implements Cache { evictor.onSpanRemoved(this, span); } - private void notifySpanAdded(CacheSpan span) { + private void notifySpanAdded(SimpleCacheSpan span) { ArrayList keyListeners = listeners.get(span.key); if (keyListeners != null) { for (int i = keyListeners.size() - 1; i >= 0; i--) { @@ -336,7 +341,7 @@ public final class SimpleCache implements Cache { evictor.onSpanAdded(this, span); } - private void notifySpanTouched(CacheSpan oldSpan, CacheSpan newSpan) { + private void notifySpanTouched(SimpleCacheSpan oldSpan, CacheSpan newSpan) { ArrayList keyListeners = listeners.get(oldSpan.key); if (keyListeners != null) { for (int i = keyListeners.size() - 1; i >= 0; i--) { @@ -348,82 +353,19 @@ public final class SimpleCache implements Cache { @Override public synchronized boolean isCached(String key, long position, long length) { - TreeSet entries = getSpansForKey(key); - if (entries == null) { - return false; - } - CacheSpan lookupSpan = CacheSpan.createLookup(key, position); - CacheSpan floorSpan = entries.floor(lookupSpan); - if (floorSpan == null || floorSpan.position + floorSpan.length <= position) { - // We don't have a span covering the start of the queried region. - return false; - } - long queryEndPosition = position + length; - long currentEndPosition = floorSpan.position + floorSpan.length; - if (currentEndPosition >= queryEndPosition) { - // floorSpan covers the queried region. - return true; - } - for (CacheSpan next : entries.tailSet(floorSpan, false)) { - if (next.position > currentEndPosition) { - // There's a hole in the cache within the queried region. - return false; - } - // We expect currentEndPosition to always equal (next.position + next.length), but - // perform a max check anyway to guard against the existence of overlapping spans. - currentEndPosition = Math.max(currentEndPosition, next.position + next.length); - if (currentEndPosition >= queryEndPosition) { - // We've found spans covering the queried region. - return true; - } - } - // We ran out of spans before covering the queried region. - return false; + CachedContent cachedContent = index.get(key); + return cachedContent != null && cachedContent.isCached(position, length); } @Override - public synchronized boolean setContentLength(String key, long length) { - Pair> entryForKey = cachedSpans.get(key); - TreeSet entries; - if (entryForKey != null) { - entries = entryForKey.second; - if (entries != null && !entries.isEmpty()) { - CacheSpan last = entries.last(); - long end = last.position + last.length; - if (end > length) { - return false; - } - } - } else { - entries = new TreeSet<>(); - } - // TODO persist the length value - setKeyValue(key, length, entries); - return true; + public synchronized void setContentLength(String key, long length) throws CacheException { + index.setContentLength(key, length); + index.store(); } @Override public synchronized long getContentLength(String key) { - Pair> entryForKey = cachedSpans.get(key); - return entryForKey == null ? C.LENGTH_UNSET : entryForKey.first; - } - - - private TreeSet getSpansForKey(String key) { - Pair> entryForKey = cachedSpans.get(key); - return entryForKey != null ? entryForKey.second : null; - } - - private void setKeyValue(String key, long length, TreeSet entries) { - cachedSpans.put(key, Pair.create(length, entries)); - } - - private void replaceSpan(CacheSpan oldSpan, CacheSpan newSpan) { - // Remove the old span from the in-memory representation. - TreeSet spansForKey = getSpansForKey(oldSpan.key); - Assertions.checkState(spansForKey.remove(oldSpan)); - // Add the updated span back into the in-memory representation. - spansForKey.add(newSpan); + return index.getContentLength(key); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java new file mode 100644 index 0000000000..8c5b7e26e7 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * This class stores span metadata in filename. + */ +/*package*/ final class SimpleCacheSpan extends CacheSpan { + + private static final String SUFFIX = ".v3.exo"; + private static final Pattern CACHE_FILE_PATTERN_V1 = Pattern.compile( + "^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$", Pattern.DOTALL); + private static final Pattern CACHE_FILE_PATTERN_V2 = Pattern.compile( + "^(.+)\\.(\\d+)\\.(\\d+)\\.v2\\.exo$", Pattern.DOTALL); + private static final Pattern CACHE_FILE_PATTERN_V3 = Pattern.compile( + "^(\\d+)\\.(\\d+)\\.(\\d+)\\.v3\\.exo$", Pattern.DOTALL); + + public static File getCacheFile(File cacheDir, int id, long position, + long lastAccessTimestamp) { + return new File(cacheDir, id + "." + position + "." + lastAccessTimestamp + SUFFIX); + } + + public static SimpleCacheSpan createLookup(String key, long position) { + return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); + } + + public static SimpleCacheSpan createOpenHole(String key, long position) { + return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); + } + + public static SimpleCacheSpan createClosedHole(String key, long position, long length) { + return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); + } + + /** + * Creates a cache span from an underlying cache file. Upgrades the file if necessary. + * + * @param file The cache file. + * @param index Cached content index. + * @return The span, or null if the file name is not correctly formatted, or if the id is not + * present in the content index. + */ + public static SimpleCacheSpan createCacheEntry(File file, CachedContentIndex index) { + String name = file.getName(); + if (!name.endsWith(SUFFIX)) { + file = upgradeFile(file, index); + if (file == null) { + return null; + } + name = file.getName(); + } + + Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name); + if (!matcher.matches()) { + return null; + } + long length = file.length(); + int id = Integer.parseInt(matcher.group(1)); + String key = index.getKeyForId(id); + return key == null ? null : new SimpleCacheSpan(key, Long.parseLong(matcher.group(2)), length, + Long.parseLong(matcher.group(3)), file); + } + + private static File upgradeFile(File file, CachedContentIndex index) { + String key; + String filename = file.getName(); + Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename); + if (matcher.matches()) { + key = Util.unescapeFileName(matcher.group(1)); + if (key == null) { + return null; + } + } else { + matcher = CACHE_FILE_PATTERN_V1.matcher(filename); + if (!matcher.matches()) { + return null; + } + key = matcher.group(1); // Keys were not escaped in version 1. + } + + File newCacheFile = getCacheFile(file.getParentFile(), index.assignIdForKey(key), + Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3))); + if (!file.renameTo(newCacheFile)) { + return null; + } + return newCacheFile; + } + + private SimpleCacheSpan(String key, long position, long length, long lastAccessTimestamp, + File file) { + super(key, position, length, lastAccessTimestamp, file); + } + + /** + * Returns a copy of this CacheSpan whose last access time stamp is set to current time. This + * doesn't copy or change the underlying cache file. + * + * @param id The cache file id. + * @return A {@link SimpleCacheSpan} with updated last access time stamp. + * @throws IllegalStateException If called on a non-cached span (i.e. {@link #isCached} is false). + */ + public SimpleCacheSpan copyWithUpdatedLastAccessTime(int id) { + Assertions.checkState(isCached); + long now = System.currentTimeMillis(); + File newCacheFile = getCacheFile(file.getParentFile(), id, position, now); + return new SimpleCacheSpan(key, position, length, now, newCacheFile); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java b/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java new file mode 100644 index 0000000000..c383c01453 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.util; + +import android.util.Log; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A helper class for performing atomic operations on a file by creating a backup file until a write + * has successfully completed. + * + *

    Atomic file guarantees file integrity by ensuring that a file has been completely written and + * sync'd to disk before removing its backup. As long as the backup file exists, the original file + * is considered to be invalid (left over from a previous attempt to write the file). + * + *

    Atomic file does not confer any file locking semantics. Do not use this class when the file + * may be accessed or modified concurrently by multiple threads or processes. The caller is + * responsible for ensuring appropriate mutual exclusion invariants whenever it accesses the file. + */ +public final class AtomicFile { + + private static final String TAG = "AtomicFile"; + + private final File baseName; + private final File backupName; + + /** + * Create a new AtomicFile for a file located at the given File path. The secondary backup file + * will be the same file path with ".bak" appended. + */ + public AtomicFile(File baseName) { + this.baseName = baseName; + backupName = new File(baseName.getPath() + ".bak"); + } + + /** Delete the atomic file. This deletes both the base and backup files. */ + public void delete() { + baseName.delete(); + backupName.delete(); + } + + /** + * Start a new write operation on the file. This returns an {@link OutputStream} to which you can + * write the new file data. If the whole data is written successfully you must call + * {@link #endWrite(OutputStream)}. On failure you should call {@link OutputStream#close()} + * only to free up resources used by it. + * + *

    Example usage: + * + *

    +   *   DataOutputStream dataOutput = null;
    +   *   try {
    +   *     OutputStream outputStream = atomicFile.startWrite();
    +   *     dataOutput = new DataOutputStream(outputStream); // Wrapper stream
    +   *     dataOutput.write(data1);
    +   *     dataOutput.write(data2);
    +   *     atomicFile.endWrite(dataOutput); // Pass wrapper stream
    +   *   } finally{
    +   *     if (dataOutput != null) {
    +   *       dataOutput.close();
    +   *     }
    +   *   }
    +   * 
    + * + *

    Note that if another thread is currently performing a write, this will simply replace + * whatever that thread is writing with the new file being written by this thread, and when the + * other thread finishes the write the new write operation will no longer be safe (or will be + * lost). You must do your own threading protection for access to AtomicFile. + */ + public OutputStream startWrite() throws IOException { + // Rename the current file so it may be used as a backup during the next read + if (baseName.exists()) { + if (!backupName.exists()) { + if (!baseName.renameTo(backupName)) { + Log.w(TAG, "Couldn't rename file " + baseName + " to backup file " + backupName); + } + } else { + baseName.delete(); + } + } + OutputStream str; + try { + str = new AtomicFileOutputStream(baseName); + } catch (FileNotFoundException e) { + File parent = baseName.getParentFile(); + if (!parent.mkdirs()) { + throw new IOException("Couldn't create directory " + baseName); + } + try { + str = new AtomicFileOutputStream(baseName); + } catch (FileNotFoundException e2) { + throw new IOException("Couldn't create " + baseName); + } + } + return str; + } + + /** + * Call when you have successfully finished writing to the stream returned by {@link + * #startWrite()}. This will close, sync, and commit the new data. The next attempt to read the + * atomic file will return the new file stream. + * + * @param str Outer-most wrapper OutputStream used to write to the stream returned by {@link + * #startWrite()}. + * @see #startWrite() + */ + public void endWrite(OutputStream str) throws IOException { + str.close(); + // If close() throws exception, the next line is skipped. + backupName.delete(); + } + + /** + * Open the atomic file for reading. If there previously was an incomplete write, this will roll + * back to the last good data before opening for read. + * + *

    Note that if another thread is currently performing a write, this will incorrectly consider + * it to be in the state of a bad write and roll back, causing the new data currently being + * written to be dropped. You must do your own threading protection for access to AtomicFile. + */ + public InputStream openRead() throws FileNotFoundException { + restoreBackup(); + return new FileInputStream(baseName); + } + + private void restoreBackup() { + if (backupName.exists()) { + baseName.delete(); + backupName.renameTo(baseName); + } + } + + private static final class AtomicFileOutputStream extends OutputStream { + + private final FileOutputStream fileOutputStream; + private boolean closed = false; + + public AtomicFileOutputStream(File file) throws FileNotFoundException { + fileOutputStream = new FileOutputStream(file); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + flush(); + try { + fileOutputStream.getFD().sync(); + } catch (IOException e) { + Log.w(TAG, "Failed to sync file descriptor:", e); + } + fileOutputStream.close(); + } + + @Override + public void flush() throws IOException { + fileOutputStream.flush(); + } + + @Override + public void write(int b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + fileOutputStream.write(b, off, len); + } + } +} diff --git a/library/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 4776e4d008..84e1f42707 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -47,6 +47,8 @@ public final class MimeTypes { public static final String AUDIO_MPEG_L1 = BASE_TYPE_AUDIO + "/mpeg-L1"; public static final String AUDIO_MPEG_L2 = BASE_TYPE_AUDIO + "/mpeg-L2"; public static final String AUDIO_RAW = BASE_TYPE_AUDIO + "/raw"; + public static final String AUDIO_ALAW = BASE_TYPE_AUDIO + "/g711-alaw"; + public static final String AUDIO_ULAW = BASE_TYPE_AUDIO + "/g711-mlaw"; public static final String AUDIO_AC3 = BASE_TYPE_AUDIO + "/ac3"; public static final String AUDIO_E_AC3 = BASE_TYPE_AUDIO + "/eac3"; public static final String AUDIO_TRUEHD = BASE_TYPE_AUDIO + "/true-hd"; @@ -70,10 +72,13 @@ public final class MimeTypes { public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; public static final String APPLICATION_TX3G = BASE_TYPE_APPLICATION + "/x-quicktime-tx3g"; - public static final String APPLICATION_MP4VTT = BASE_TYPE_APPLICATION + "/x-mp4vtt"; + public static final String APPLICATION_MP4VTT = BASE_TYPE_APPLICATION + "/x-mp4-vtt"; + public static final String APPLICATION_MP4CEA608 = BASE_TYPE_APPLICATION + "/x-mp4-cea-608"; public static final String APPLICATION_RAWCC = BASE_TYPE_APPLICATION + "/x-rawcc"; public static final String APPLICATION_VOBSUB = BASE_TYPE_APPLICATION + "/vobsub"; public static final String APPLICATION_PGS = BASE_TYPE_APPLICATION + "/pgs"; + public static final String APPLICATION_SCTE35 = BASE_TYPE_APPLICATION + "/x-scte35"; + public static final String APPLICATION_CAMERA_MOTION = BASE_TYPE_APPLICATION + "/x-camera-motion"; private MimeTypes() {} @@ -84,7 +89,7 @@ public final class MimeTypes { * @return Whether the top level type is audio. */ public static boolean isAudio(String mimeType) { - return getTopLevelType(mimeType).equals(BASE_TYPE_AUDIO); + return BASE_TYPE_AUDIO.equals(getTopLevelType(mimeType)); } /** @@ -94,7 +99,7 @@ public final class MimeTypes { * @return Whether the top level type is video. */ public static boolean isVideo(String mimeType) { - return getTopLevelType(mimeType).equals(BASE_TYPE_VIDEO); + return BASE_TYPE_VIDEO.equals(getTopLevelType(mimeType)); } /** @@ -104,7 +109,7 @@ public final class MimeTypes { * @return Whether the top level type is text. */ public static boolean isText(String mimeType) { - return getTopLevelType(mimeType).equals(BASE_TYPE_TEXT); + return BASE_TYPE_TEXT.equals(getTopLevelType(mimeType)); } /** @@ -114,7 +119,7 @@ public final class MimeTypes { * @return Whether the top level type is application. */ public static boolean isApplication(String mimeType) { - return getTopLevelType(mimeType).equals(BASE_TYPE_APPLICATION); + return BASE_TYPE_APPLICATION.equals(getTopLevelType(mimeType)); } @@ -211,10 +216,10 @@ public final class MimeTypes { } else if (isVideo(mimeType)) { return C.TRACK_TYPE_VIDEO; } else if (isText(mimeType) || APPLICATION_CEA608.equals(mimeType) - || APPLICATION_SUBRIP.equals(mimeType) || APPLICATION_TTML.equals(mimeType) - || APPLICATION_TX3G.equals(mimeType) || APPLICATION_MP4VTT.equals(mimeType) - || APPLICATION_RAWCC.equals(mimeType) || APPLICATION_VOBSUB.equals(mimeType) - || APPLICATION_PGS.equals(mimeType)) { + || APPLICATION_CEA708.equals(mimeType) || APPLICATION_SUBRIP.equals(mimeType) + || APPLICATION_TTML.equals(mimeType) || APPLICATION_TX3G.equals(mimeType) + || APPLICATION_MP4VTT.equals(mimeType) || APPLICATION_RAWCC.equals(mimeType) + || APPLICATION_VOBSUB.equals(mimeType) || APPLICATION_PGS.equals(mimeType)) { return C.TRACK_TYPE_TEXT; } else if (APPLICATION_ID3.equals(mimeType)) { return C.TRACK_TYPE_METADATA; @@ -237,9 +242,12 @@ public final class MimeTypes { * Returns the top-level type of {@code mimeType}. * * @param mimeType The mimeType whose top-level type is required. - * @return The top-level type. + * @return The top-level type, or null if the mimeType is null. */ private static String getTopLevelType(String mimeType) { + if (mimeType == null) { + return null; + } int indexOfSlash = mimeType.indexOf('/'); if (indexOfSlash == -1) { throw new IllegalArgumentException("Invalid mime type: " + mimeType); diff --git a/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index b306fbf76e..b8d635a053 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -300,9 +300,9 @@ public final class ParsableByteArray { */ public int readLittleEndianInt() { return (data[position++] & 0xFF) - | (data[position++] & 0xFF) << 8 - | (data[position++] & 0xFF) << 16 - | (data[position++] & 0xFF) << 24; + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 24; } /** @@ -423,6 +423,27 @@ public final class ParsableByteArray { return readString(length, Charset.defaultCharset()); } + /** + * Reads the next {@code length} bytes as UTF-8 characters. A terminating NUL byte is ignored, + * if present. + * + * @param length The number of bytes to read. + * @return The string encoded by the bytes. + */ + public String readNullTerminatedString(int length) { + if (length == 0) { + return ""; + } + int stringLength = length; + int lastIndex = position + length - 1; + if (lastIndex < limit && data[lastIndex] == 0) { + stringLength--; + } + String result = new String(data, position, stringLength, Charset.defaultCharset()); + position += length; + return result; + } + /** * Reads the next {@code length} bytes as characters in the specified {@link Charset}. * diff --git a/library/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java b/library/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java index cc6a17913b..fb61d3ba4a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java @@ -46,7 +46,7 @@ public final class PriorityTaskManager { private final PriorityQueue queue; private int highestPriority; - private PriorityTaskManager() { + public PriorityTaskManager() { queue = new PriorityQueue<>(10, Collections.reverseOrder()); highestPriority = Integer.MIN_VALUE; } diff --git a/library/src/main/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java b/library/src/main/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java new file mode 100644 index 0000000000..a3d1d4d02e --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * This is a subclass of {@link BufferedOutputStream} with a {@link #reset(OutputStream)} method + * that allows an instance to be re-used with another underlying output stream. + */ +public final class ReusableBufferedOutputStream extends BufferedOutputStream { + + private boolean closed; + + public ReusableBufferedOutputStream(OutputStream out) { + super(out); + } + + public ReusableBufferedOutputStream(OutputStream out, int size) { + super(out, size); + } + + @Override + public void close() throws IOException { + closed = true; + + Throwable thrown = null; + try { + flush(); + } catch (Throwable e) { + thrown = e; + } + try { + out.close(); + } catch (Throwable e) { + if (thrown == null) { + thrown = e; + } + } + if (thrown != null) { + Util.sneakyThrow(thrown); + } + } + + /** + * Resets this stream and uses the given output stream for writing. This stream must be closed + * before resetting. + * + * @param out New output stream to be used for writing. + * @throws IllegalStateException If the stream isn't closed. + */ + public void reset(OutputStream out) { + Assertions.checkState(closed); + this.out = out; + closed = false; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/src/main/java/com/google/android/exoplayer2/util/Util.java index c4505fd8b9..4477de7abb 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -31,19 +31,16 @@ import android.view.Display; import android.view.WindowManager; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.math.BigDecimal; import java.nio.charset.Charset; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.text.ParseException; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -67,7 +64,7 @@ public final class Util { * overridden for local testing. */ public static final int SDK_INT = - (Build.VERSION.SDK_INT == 23 && Build.VERSION.CODENAME.charAt(0) == 'N') ? 24 + (Build.VERSION.SDK_INT == 25 && Build.VERSION.CODENAME.charAt(0) == 'O') ? 26 : Build.VERSION.SDK_INT; /** @@ -88,16 +85,21 @@ public final class Util { */ public static final String MODEL = Build.MODEL; + /** + * A concise description of the device that it can be useful to log for debugging purposes. + */ + public static final String DEVICE_DEBUG_INFO = DEVICE + ", " + MODEL + ", " + MANUFACTURER + ", " + + SDK_INT; + private static final String TAG = "Util"; private static final Pattern XS_DATE_TIME_PATTERN = Pattern.compile( "(\\d\\d\\d\\d)\\-(\\d\\d)\\-(\\d\\d)[Tt]" + "(\\d\\d):(\\d\\d):(\\d\\d)(\\.(\\d+))?" - + "([Zz]|((\\+|\\-)(\\d\\d):(\\d\\d)))?"); + + "([Zz]|((\\+|\\-)(\\d\\d):?(\\d\\d)))?"); private static final Pattern XS_DURATION_PATTERN = Pattern.compile("^(-)?P(([0-9]*)Y)?(([0-9]*)M)?(([0-9]*)D)?" + "(T(([0-9]*)H)?(([0-9]*)M)?(([0-9.]*)S)?)?$"); private static final Pattern ESCAPED_CHARACTER_PATTERN = Pattern.compile("%([A-Fa-f0-9]{2})"); - private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); private Util() {} @@ -208,20 +210,25 @@ public final class Util { */ public static void closeQuietly(DataSource dataSource) { try { - dataSource.close(); + if (dataSource != null) { + dataSource.close(); + } } catch (IOException e) { // Ignore. } } /** - * Closes an {@link OutputStream}, suppressing any {@link IOException} that may occur. + * Closes a {@link Closeable}, suppressing any {@link IOException} that may occur. Both {@link + * java.io.OutputStream} and {@link InputStream} are {@code Closeable}. * - * @param outputStream The {@link OutputStream} to close. + * @param closeable The {@link Closeable} to close. */ - public static void closeQuietly(OutputStream outputStream) { + public static void closeQuietly(Closeable closeable) { try { - outputStream.close(); + if (closeable != null) { + closeable.close(); + } } catch (IOException e) { // Ignore. } @@ -292,110 +299,167 @@ public final class Util { } /** - * Returns the index of the largest value in an array that is less than (or optionally equal to) - * a specified value. + * Returns the index of the largest element in {@code array} that is less than (or optionally + * equal to) a specified {@code value}. *

    - * The search is performed using a binary search algorithm, so the array must be sorted. + * The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the first one will be returned. * - * @param a The array to search. + * @param array The array to search. * @param value The value being searched for. * @param inclusive If the value is present in the array, whether to return the corresponding - * index. If false then the returned index corresponds to the largest value in the array that - * is strictly less than the value. + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than - * the smallest value in the array. If false then -1 will be returned. + * the smallest element in the array. If false then -1 will be returned. + * @return The index of the largest element in {@code array} that is less than (or optionally + * equal to) {@code value}. */ - public static int binarySearchFloor(int[] a, int value, boolean inclusive, boolean stayInBounds) { - int index = Arrays.binarySearch(a, value); - index = index < 0 ? -(index + 2) : (inclusive ? index : (index - 1)); - return stayInBounds ? Math.max(0, index) : index; - } - - /** - * Returns the index of the largest value in an array that is less than (or optionally equal to) - * a specified value. - *

    - * The search is performed using a binary search algorithm, so the array must be sorted. - * - * @param a The array to search. - * @param value The value being searched for. - * @param inclusive If the value is present in the array, whether to return the corresponding - * index. If false then the returned index corresponds to the largest value in the array that - * is strictly less than the value. - * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than - * the smallest value in the array. If false then -1 will be returned. - */ - public static int binarySearchFloor(long[] a, long value, boolean inclusive, + public static int binarySearchFloor(int[] array, int value, boolean inclusive, boolean stayInBounds) { - int index = Arrays.binarySearch(a, value); - index = index < 0 ? -(index + 2) : (inclusive ? index : (index - 1)); + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = -(index + 2); + } else { + while ((--index) >= 0 && array[index] == value) {} + if (inclusive) { + index++; + } + } return stayInBounds ? Math.max(0, index) : index; } /** - * Returns the index of the smallest value in an array that is greater than (or optionally equal - * to) a specified value. + * Returns the index of the largest element in {@code array} that is less than (or optionally + * equal to) a specified {@code value}. *

    - * The search is performed using a binary search algorithm, so the array must be sorted. + * The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the first one will be returned. * - * @param a The array to search. + * @param array The array to search. * @param value The value being searched for. * @param inclusive If the value is present in the array, whether to return the corresponding - * index. If false then the returned index corresponds to the largest value in the array that - * is strictly less than the value. + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. + * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than + * the smallest element in the array. If false then -1 will be returned. + * @return The index of the largest element in {@code array} that is less than (or optionally + * equal to) {@code value}. + */ + public static int binarySearchFloor(long[] array, long value, boolean inclusive, + boolean stayInBounds) { + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = -(index + 2); + } else { + while ((--index) >= 0 && array[index] == value) {} + if (inclusive) { + index++; + } + } + return stayInBounds ? Math.max(0, index) : index; + } + + /** + * Returns the index of the smallest element in {@code array} that is greater than (or optionally + * equal to) a specified {@code value}. + *

    + * The search is performed using a binary search algorithm, so the array must be sorted. If + * the array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the last one will be returned. + * + * @param array The array to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the array, whether to return the corresponding + * index. If false then the returned index corresponds to the smallest element strictly + * greater than the value. * @param stayInBounds If true, then {@code (a.length - 1)} will be returned in the case that the - * value is greater than the largest value in the array. If false then {@code a.length} will + * value is greater than the largest element in the array. If false then {@code a.length} will * be returned. + * @return The index of the smallest element in {@code array} that is greater than (or optionally + * equal to) {@code value}. */ - public static int binarySearchCeil(long[] a, long value, boolean inclusive, + public static int binarySearchCeil(long[] array, long value, boolean inclusive, boolean stayInBounds) { - int index = Arrays.binarySearch(a, value); - index = index < 0 ? ~index : (inclusive ? index : (index + 1)); - return stayInBounds ? Math.min(a.length - 1, index) : index; + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = ~index; + } else { + while ((++index) < array.length && array[index] == value) {} + if (inclusive) { + index--; + } + } + return stayInBounds ? Math.min(array.length - 1, index) : index; } /** - * Returns the index of the largest value in an list that is less than (or optionally equal to) - * a specified value. + * Returns the index of the largest element in {@code list} that is less than (or optionally equal + * to) a specified {@code value}. *

    - * The search is performed using a binary search algorithm, so the list must be sorted. + * The search is performed using a binary search algorithm, so the list must be sorted. If the + * list contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the first one will be returned. * * @param The type of values being searched. * @param list The list to search. * @param value The value being searched for. * @param inclusive If the value is present in the list, whether to return the corresponding - * index. If false then the returned index corresponds to the largest value in the list that - * is strictly less than the value. + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than - * the smallest value in the list. If false then -1 will be returned. + * the smallest element in the list. If false then -1 will be returned. + * @return The index of the largest element in {@code list} that is less than (or optionally equal + * to) {@code value}. */ public static int binarySearchFloor(List> list, T value, boolean inclusive, boolean stayInBounds) { int index = Collections.binarySearch(list, value); - index = index < 0 ? -(index + 2) : (inclusive ? index : (index - 1)); + if (index < 0) { + index = -(index + 2); + } else { + while ((--index) >= 0 && list.get(index).compareTo(value) == 0) {} + if (inclusive) { + index++; + } + } return stayInBounds ? Math.max(0, index) : index; } /** - * Returns the index of the smallest value in an list that is greater than (or optionally equal - * to) a specified value. + * Returns the index of the smallest element in {@code list} that is greater than (or optionally + * equal to) a specified value. *

    - * The search is performed using a binary search algorithm, so the list must be sorted. + * The search is performed using a binary search algorithm, so the list must be sorted. If the + * list contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the last one will be returned. * * @param The type of values being searched. * @param list The list to search. * @param value The value being searched for. * @param inclusive If the value is present in the list, whether to return the corresponding - * index. If false then the returned index corresponds to the smallest value in the list that - * is strictly greater than the value. + * index. If false then the returned index corresponds to the smallest element strictly + * greater than the value. * @param stayInBounds If true, then {@code (list.size() - 1)} will be returned in the case that - * the value is greater than the largest value in the list. If false then {@code list.size()} - * will be returned. + * the value is greater than the largest element in the list. If false then + * {@code list.size()} will be returned. + * @return The index of the smallest element in {@code list} that is greater than (or optionally + * equal to) {@code value}. */ public static int binarySearchCeil(List> list, T value, boolean inclusive, boolean stayInBounds) { int index = Collections.binarySearch(list, value); - index = index < 0 ? ~index : (inclusive ? index : (index + 1)); + if (index < 0) { + index = ~index; + } else { + int listSize = list.size(); + while ((++index) < listSize && list.get(index).compareTo(value) == 0) {} + if (inclusive) { + index--; + } + } return stayInBounds ? Math.min(list.size() - 1, index) : index; } @@ -436,11 +500,12 @@ public final class Util { * * @param value The attribute value to decode. * @return The parsed timestamp in milliseconds since the epoch. + * @throws ParserException if an error occurs parsing the dateTime attribute value. */ - public static long parseXsDateTime(String value) throws ParseException { + public static long parseXsDateTime(String value) throws ParserException { Matcher matcher = XS_DATE_TIME_PATTERN.matcher(value); if (!matcher.matches()) { - throw new ParseException("Invalid date/time format: " + value, 0); + throw new ParserException("Invalid date/time format: " + value); } int timezoneShift; @@ -630,21 +695,6 @@ public final class Util { return data; } - /** - * Returns a hex string representation of the given byte array. - * - * @param bytes The byte array. - */ - public static String getHexString(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - int i = 0; - for (byte v : bytes) { - hexChars[i++] = HEX_DIGITS[(v >> 4) & 0xf]; - hexChars[i++] = HEX_DIGITS[v & 0xf]; - } - return new String(hexChars); - } - /** * Returns a string with comma delimited simple names of each object's class. * @@ -851,6 +901,19 @@ public final class Util { return builder.toString(); } + /** + * A hacky method that always throws {@code t} even if {@code t} is a checked exception, + * and is not declared to be thrown. + */ + public static void sneakyThrow(Throwable t) { + Util.sneakyThrowInternal(t); + } + + @SuppressWarnings("unchecked") + private static void sneakyThrowInternal(Throwable t) throws T { + throw (T) t; + } + /** * Returns the result of updating a CRC with the specified bytes in a "most significant bit first" * order. @@ -869,22 +932,6 @@ public final class Util { return initialValue; } - /** - * Returns the SHA-1 digest of {@code input} as a hex string. - * - * @param input The string whose SHA-1 digest is required. - */ - public static String sha1(String input) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - byte[] bytes = input.getBytes("UTF-8"); - digest.update(bytes, 0, bytes.length); - return getHexString(digest.digest()); - } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - /** * Gets the physical size of the default display, in pixels. * diff --git a/library/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index b5a01f0a28..2a13953106 100644 --- a/library/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -23,6 +23,7 @@ import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; import android.os.SystemClock; +import android.util.Log; import android.view.Surface; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -56,7 +57,6 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final VideoFrameReleaseTimeHelper frameReleaseTimeHelper; private final EventDispatcher eventDispatcher; private final long allowedJoiningTimeMs; - private final int videoScalingMode; private final int maxDroppedFramesToNotify; private final boolean deviceNeedsAutoFrcWorkaround; @@ -64,6 +64,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private CodecMaxValues codecMaxValues; private Surface surface; + @C.VideoScalingMode + private int scalingMode; private boolean renderedFirstFrame; private long joiningDeadlineMs; private long droppedFrameAccumulationStartTimeMs; @@ -84,32 +86,25 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { /** * @param context A context. * @param mediaCodecSelector A decoder selector. - * @param videoScalingMode The scaling mode to pass to - * {@link MediaCodec#setVideoScalingMode(int)}. */ - public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - int videoScalingMode) { - this(context, mediaCodecSelector, videoScalingMode, 0); + public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector) { + this(context, mediaCodecSelector, 0); } /** * @param context A context. * @param mediaCodecSelector A decoder selector. - * @param videoScalingMode The scaling mode to pass to - * {@link MediaCodec#setVideoScalingMode(int)}. * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer * can attempt to seamlessly join an ongoing playback. */ public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - int videoScalingMode, long allowedJoiningTimeMs) { - this(context, mediaCodecSelector, videoScalingMode, allowedJoiningTimeMs, null, null, -1); + long allowedJoiningTimeMs) { + this(context, mediaCodecSelector, allowedJoiningTimeMs, null, null, -1); } /** * @param context A context. * @param mediaCodecSelector A decoder selector. - * @param videoScalingMode The scaling mode to pass to - * {@link MediaCodec#setVideoScalingMode(int)}. * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer * can attempt to seamlessly join an ongoing playback. * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be @@ -119,17 +114,15 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. */ public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - int videoScalingMode, long allowedJoiningTimeMs, Handler eventHandler, - VideoRendererEventListener eventListener, int maxDroppedFrameCountToNotify) { - this(context, mediaCodecSelector, videoScalingMode, allowedJoiningTimeMs, null, false, - eventHandler, eventListener, maxDroppedFrameCountToNotify); + long allowedJoiningTimeMs, Handler eventHandler, VideoRendererEventListener eventListener, + int maxDroppedFrameCountToNotify) { + this(context, mediaCodecSelector, allowedJoiningTimeMs, null, false, eventHandler, + eventListener, maxDroppedFrameCountToNotify); } /** * @param context A context. * @param mediaCodecSelector A decoder selector. - * @param videoScalingMode The scaling mode to pass to - * {@link MediaCodec#setVideoScalingMode(int)}. * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer * can attempt to seamlessly join an ongoing playback. * @param drmSessionManager For use with encrypted content. May be null if support for encrypted @@ -146,12 +139,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. */ public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - int videoScalingMode, long allowedJoiningTimeMs, - DrmSessionManager drmSessionManager, + long allowedJoiningTimeMs, DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, Handler eventHandler, VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { super(C.TRACK_TYPE_VIDEO, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys); - this.videoScalingMode = videoScalingMode; this.allowedJoiningTimeMs = allowedJoiningTimeMs; this.maxDroppedFramesToNotify = maxDroppedFramesToNotify; frameReleaseTimeHelper = new VideoFrameReleaseTimeHelper(context); @@ -162,9 +153,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { currentHeight = Format.NO_VALUE; currentPixelWidthHeightRatio = Format.NO_VALUE; pendingPixelWidthHeightRatio = Format.NO_VALUE; - lastReportedWidth = Format.NO_VALUE; - lastReportedHeight = Format.NO_VALUE; - lastReportedPixelWidthHeightRatio = Format.NO_VALUE; + scalingMode = C.VIDEO_SCALING_MODE_DEFAULT; + clearLastReportedVideoSize(); } @Override @@ -182,7 +172,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } } MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, - requiresSecureDecryption); + requiresSecureDecryption, false); if (decoderInfo == null) { return FORMAT_UNSUPPORTED_SUBTYPE; } @@ -198,6 +188,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } } else { decoderCapable = format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize(); + if (!decoderCapable) { + Log.d(TAG, "FalseCheck [legacyFrameSize, " + format.width + "x" + format.height + "] [" + + Util.DEVICE_DEBUG_INFO + "]"); + } } } @@ -267,9 +261,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { currentHeight = Format.NO_VALUE; currentPixelWidthHeightRatio = Format.NO_VALUE; pendingPixelWidthHeightRatio = Format.NO_VALUE; - lastReportedWidth = Format.NO_VALUE; - lastReportedHeight = Format.NO_VALUE; - lastReportedPixelWidthHeightRatio = Format.NO_VALUE; + clearLastReportedVideoSize(); frameReleaseTimeHelper.disable(); try { super.onDisabled(); @@ -283,21 +275,30 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { public void handleMessage(int messageType, Object message) throws ExoPlaybackException { if (messageType == C.MSG_SET_SURFACE) { setSurface((Surface) message); + } else if (messageType == C.MSG_SET_SCALING_MODE) { + scalingMode = (Integer) message; + MediaCodec codec = getCodec(); + if (codec != null) { + setVideoScalingMode(codec, scalingMode); + } } else { super.handleMessage(messageType, message); } } private void setSurface(Surface surface) throws ExoPlaybackException { - if (this.surface == surface) { - return; - } + // Clear state so that we always call the event listener with the video size and when a frame + // is rendered, even if the surface hasn't changed. renderedFirstFrame = false; - this.surface = surface; - int state = getState(); - if (state == STATE_ENABLED || state == STATE_STARTED) { - releaseCodec(); - maybeInitCodec(); + clearLastReportedVideoSize(); + // We only need to actually release and reinitialize the codec if the surface has changed. + if (this.surface != surface) { + this.surface = surface; + int state = getState(); + if (state == STATE_ENABLED || state == STATE_STARTED) { + releaseCodec(); + maybeInitCodec(); + } } } @@ -354,7 +355,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { currentUnappliedRotationDegrees = pendingRotationDegrees; } // Must be applied each time the output format changes. - codec.setVideoScalingMode(videoScalingMode); + setVideoScalingMode(codec, scalingMode); } @Override @@ -484,6 +485,36 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } } + private void clearLastReportedVideoSize() { + lastReportedWidth = Format.NO_VALUE; + lastReportedHeight = Format.NO_VALUE; + lastReportedPixelWidthHeightRatio = Format.NO_VALUE; + lastReportedUnappliedRotationDegrees = Format.NO_VALUE; + } + + private void maybeNotifyVideoSizeChanged() { + if (lastReportedWidth != currentWidth || lastReportedHeight != currentHeight + || lastReportedUnappliedRotationDegrees != currentUnappliedRotationDegrees + || lastReportedPixelWidthHeightRatio != currentPixelWidthHeightRatio) { + eventDispatcher.videoSizeChanged(currentWidth, currentHeight, currentUnappliedRotationDegrees, + currentPixelWidthHeightRatio); + lastReportedWidth = currentWidth; + lastReportedHeight = currentHeight; + lastReportedUnappliedRotationDegrees = currentUnappliedRotationDegrees; + lastReportedPixelWidthHeightRatio = currentPixelWidthHeightRatio; + } + } + + private void maybeNotifyDroppedFrames() { + if (droppedFrames > 0) { + long now = SystemClock.elapsedRealtime(); + long elapsedMs = now - droppedFrameAccumulationStartTimeMs; + eventDispatcher.droppedFrames(droppedFrames, elapsedMs); + droppedFrames = 0; + droppedFrameAccumulationStartTimeMs = now; + } + } + @SuppressLint("InlinedApi") private static MediaFormat getMediaFormat(Format format, CodecMaxValues codecMaxValues, boolean deviceNeedsAutoFrcWorkaround) { @@ -579,27 +610,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { return (maxPixels * 3) / (2 * minCompressionRatio); } - private void maybeNotifyVideoSizeChanged() { - if (lastReportedWidth != currentWidth || lastReportedHeight != currentHeight - || lastReportedUnappliedRotationDegrees != currentUnappliedRotationDegrees - || lastReportedPixelWidthHeightRatio != currentPixelWidthHeightRatio) { - eventDispatcher.videoSizeChanged(currentWidth, currentHeight, currentUnappliedRotationDegrees, - currentPixelWidthHeightRatio); - lastReportedWidth = currentWidth; - lastReportedHeight = currentHeight; - lastReportedUnappliedRotationDegrees = currentUnappliedRotationDegrees; - lastReportedPixelWidthHeightRatio = currentPixelWidthHeightRatio; - } - } - - private void maybeNotifyDroppedFrames() { - if (droppedFrames > 0) { - long now = SystemClock.elapsedRealtime(); - long elapsedMs = now - droppedFrameAccumulationStartTimeMs; - eventDispatcher.droppedFrames(droppedFrames, elapsedMs); - droppedFrames = 0; - droppedFrameAccumulationStartTimeMs = now; - } + private static void setVideoScalingMode(MediaCodec codec, int scalingMode) { + codec.setVideoScalingMode(scalingMode); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/library/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java index 4c7d8a62c1..53d6a76b8d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java +++ b/library/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -69,7 +69,8 @@ public interface VideoRendererEventListener { void onDroppedFrames(int count, long elapsedMs); /** - * Called each time there's a change in the size of the video being rendered. + * Called before a frame is rendered for the first time since setting the surface, and each time + * there's a change in the size, rotation or pixel aspect ratio of the video being rendered. * * @param width The video width in pixels. * @param height The video height in pixels. diff --git a/library/src/main/res/layout/exo_playback_control_view.xml b/library/src/main/res/layout/exo_playback_control_view.xml index a0be4a8149..f8ef5a6fdd 100644 --- a/library/src/main/res/layout/exo_playback_control_view.xml +++ b/library/src/main/res/layout/exo_playback_control_view.xml @@ -17,9 +17,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" + android:layoutDirection="ltr" android:background="#CC000000" - android:orientation="vertical" - android:layoutDirection="ltr"> + android:orientation="vertical"> - - - + - + + - @@ -52,34 +51,35 @@ - - - diff --git a/library/src/main/res/layout/exo_simple_player_view.xml b/library/src/main/res/layout/exo_simple_player_view.xml index 99945b8d25..1f59b7796d 100644 --- a/library/src/main/res/layout/exo_simple_player_view.xml +++ b/library/src/main/res/layout/exo_simple_player_view.xml @@ -13,28 +13,37 @@ See the License for the specific language governing permissions and limitations under the License. --> - + - - + - + + + + - - + + + diff --git a/library/src/main/res/values-v11/styles.xml b/library/src/main/res/values-v11/styles.xml new file mode 100644 index 0000000000..6f77440287 --- /dev/null +++ b/library/src/main/res/values-v11/styles.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/library/src/main/res/values/attrs.xml b/library/src/main/res/values/attrs.xml index d58882c0aa..b5c01b4575 100644 --- a/library/src/main/res/values/attrs.xml +++ b/library/src/main/res/values/attrs.xml @@ -15,22 +15,36 @@ --> + + + + + + + + + + + + - + + + @@ -41,6 +55,7 @@ + diff --git a/library/src/main/res/values/constants.xml b/library/src/main/res/values/constants.xml new file mode 100644 index 0000000000..5c86696ea0 --- /dev/null +++ b/library/src/main/res/values/constants.xml @@ -0,0 +1,21 @@ + + + + + 71dp + 52dp + + diff --git a/library/src/main/res/values/ids.xml b/library/src/main/res/values/ids.xml new file mode 100644 index 0000000000..61db83825e --- /dev/null +++ b/library/src/main/res/values/ids.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/library/src/main/res/values/styles.xml b/library/src/main/res/values/styles.xml index fe1e26e967..a67cffe420 100644 --- a/library/src/main/res/values/styles.xml +++ b/library/src/main/res/values/styles.xml @@ -17,8 +17,8 @@ + + + + diff --git a/playbacktests/build.gradle b/playbacktests/build.gradle index f6d59e758c..c099e2c86e 100644 --- a/playbacktests/build.gradle +++ b/playbacktests/build.gradle @@ -11,14 +11,14 @@ // 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. -apply plugin: 'com.android.application' +apply plugin: 'com.android.library' android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion defaultConfig { - minSdkVersion 16 + minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion } diff --git a/playbacktests/src/androidTest/AndroidManifest.xml b/playbacktests/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..2f7bbe6d7c --- /dev/null +++ b/playbacktests/src/androidTest/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp3PlaybackTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp3PlaybackTest.java new file mode 100644 index 0000000000..b640a058ee --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp3PlaybackTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.playbacktests; + +import android.annotation.TargetApi; +import android.net.Uri; +import android.test.ActivityInstrumentationTestCase2; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.playbacktests.util.ActionSchedule; +import com.google.android.exoplayer2.playbacktests.util.ExoHostedTest; +import com.google.android.exoplayer2.playbacktests.util.HostActivity; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.ClosedSource; +import com.google.android.exoplayer2.util.Util; + +/** + * Tests MP3 playback using {@link ExoPlayer}. + */ +@ClosedSource(reason = "Not yet ready") +public final class Mp3PlaybackTest extends ActivityInstrumentationTestCase2 { + + private static final String TAG = "Mp3PlaybackTest"; + private static final String URL = "http://storage.googleapis.com/exoplayer-test-media-0/play.mp3"; + + private static final long TEST_TIMEOUT_MS = 2 * 60 * 1000; + + public Mp3PlaybackTest() { + super(HostActivity.class); + } + + public void testPlayback() { + if (Util.SDK_INT < 16) { + // Pass. + return; + } + Mp3HostedTest test = new Mp3HostedTest(URL, true); + getActivity().runTest(test, TEST_TIMEOUT_MS); + } + + public void testPlaybackWithSeeking() { + if (Util.SDK_INT < 16) { + // Pass. + return; + } + Mp3HostedTest test = new Mp3HostedTest(URL, false); + ActionSchedule schedule = new ActionSchedule.Builder(TAG) + .delay(5000).seek(30000) + .delay(5000).seek(0) + .delay(5000).seek(30000) + .delay(5000).stop() + .build(); + test.setSchedule(schedule); + getActivity().runTest(test, TEST_TIMEOUT_MS); + } + + @TargetApi(16) + private static class Mp3HostedTest extends ExoHostedTest { + + private final Uri uri; + + public Mp3HostedTest(String uriString, boolean fullPlaybackNoSeeking) { + super("Mp3PlaybackTest", fullPlaybackNoSeeking); + uri = Uri.parse(uriString); + } + + @Override + public MediaSource buildSource(HostActivity host, String userAgent, + TransferListener mediaTransferListener) { + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(host, userAgent, + mediaTransferListener); + return new ExtractorMediaSource(uri, dataSourceFactory, Mp3Extractor.FACTORY, null, null); + } + + } + +} diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp4PlaybackTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp4PlaybackTest.java new file mode 100644 index 0000000000..3069063b65 --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/Mp4PlaybackTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.playbacktests; + +import android.annotation.TargetApi; +import android.net.Uri; +import android.test.ActivityInstrumentationTestCase2; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; +import com.google.android.exoplayer2.playbacktests.util.DecoderCountersUtil; +import com.google.android.exoplayer2.playbacktests.util.ExoHostedTest; +import com.google.android.exoplayer2.playbacktests.util.HostActivity; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.ClosedSource; +import com.google.android.exoplayer2.util.Util; + +/** + * Tests MP4 playback using {@link ExoPlayer}. + */ +@ClosedSource(reason = "Not yet ready") +public final class Mp4PlaybackTest extends ActivityInstrumentationTestCase2 { + + private static final String SOURCE_URL = "http://redirector.c.youtube.com/videoplayback?id=604ed5" + + "ce52eda7ee&itag=22&source=youtube&sparams=ip,ipbits,expire,source,id&ip=0.0.0.0&ipbits=0&" + + "expire=19000000000&signature=513F28C7FDCBEC60A66C86C9A393556C99DC47FB.04C88036EEE12565A1ED" + + "864A875A58F15D8B5300&key=ik0"; + private static final String VIDEO_TAG = "Video"; + + private static final long TEST_TIMEOUT_MS = 15 * 60 * 1000; + private static final float MAX_DROPPED_VIDEO_FRAME_FRACTION = 0.01f; + private static final int EXPECTED_VIDEO_FRAME_COUNT = 14316; + + public Mp4PlaybackTest() { + super(HostActivity.class); + } + + public void testPlayback() { + if (Util.SDK_INT < 16) { + // Pass. + return; + } + Mp4HostedTest test = new Mp4HostedTest(SOURCE_URL, true); + getActivity().runTest(test, TEST_TIMEOUT_MS); + } + + @TargetApi(16) + private static class Mp4HostedTest extends ExoHostedTest { + + private final Uri uri; + + public Mp4HostedTest(String uriString, boolean fullPlaybackNoSeeking) { + super("Mp4PlaybackTest", fullPlaybackNoSeeking); + uri = Uri.parse(uriString); + } + + @Override + public MediaSource buildSource(HostActivity host, String userAgent, + TransferListener mediaTransferListener) { + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(host, userAgent); + return new ExtractorMediaSource(uri, dataSourceFactory, Mp4Extractor.FACTORY, null, null); + } + + @Override + public void assertPassed(DecoderCounters audioCounters, DecoderCounters videoCounters) { + assertEquals(1, videoCounters.decoderInitCount); + assertEquals(1, videoCounters.decoderReleaseCount); + DecoderCountersUtil.assertSkippedOutputBufferCount(VIDEO_TAG, videoCounters, 0); + + // We allow one fewer output buffer due to the way that MediaCodecRenderer and the + // underlying decoders handle the end of stream. This should be tightened up in the future. + DecoderCountersUtil.assertTotalOutputBufferCount(VIDEO_TAG, videoCounters, + EXPECTED_VIDEO_FRAME_COUNT - 1, EXPECTED_VIDEO_FRAME_COUNT); + + int droppedFrameLimit = (int) Math.ceil(MAX_DROPPED_VIDEO_FRAME_FRACTION + * DecoderCountersUtil.getTotalOutputBuffers(videoCounters)); + DecoderCountersUtil.assertDroppedOutputBufferLimit(VIDEO_TAG, videoCounters, + droppedFrameLimit); + } + + } + +} diff --git a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java similarity index 97% rename from playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java rename to playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java index d9eade1c2f..8c1ee45e0e 100644 --- a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java @@ -19,15 +19,18 @@ import android.annotation.TargetApi; import android.media.MediaDrm; import android.media.UnsupportedSchemeException; import android.net.Uri; -import android.os.Handler; -import android.os.Looper; import android.test.ActivityInstrumentationTestCase2; import android.util.Log; +import android.view.Surface; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; import com.google.android.exoplayer2.drm.StreamingDrmSessionManager; import com.google.android.exoplayer2.drm.UnsupportedDrmException; @@ -35,6 +38,7 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.playbacktests.util.ActionSchedule; +import com.google.android.exoplayer2.playbacktests.util.DebugSimpleExoPlayer; import com.google.android.exoplayer2.playbacktests.util.DecoderCountersUtil; import com.google.android.exoplayer2.playbacktests.util.ExoHostedTest; import com.google.android.exoplayer2.playbacktests.util.HostActivity; @@ -629,7 +633,7 @@ public final class DashTest extends ActivityInstrumentationTestCase2 buildDrmSessionManager( + final String userAgent) { + StreamingDrmSessionManager drmSessionManager = null; if (isWidevineEncrypted) { try { // Force L3 if secure decoder is not available. - boolean forceL3Widevine = MediaCodecUtil.getDecoderInfo(videoMimeType, true) == null; + boolean forceL3Widevine = + MediaCodecUtil.getDecoderInfo(videoMimeType, true, false) == null; MediaDrm mediaDrm = new MediaDrm(WIDEVINE_UUID); String securityProperty = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY); String widevineContentId = forceL3Widevine ? WIDEVINE_SW_CRYPTO_CONTENT_ID @@ -727,7 +733,17 @@ public final class DashTest extends ActivityInstrumentationTestCase2 drmSessionManager) { + SimpleExoPlayer player = new DebugSimpleExoPlayer(host, trackSelector, + new DefaultLoadControl(), drmSessionManager); + player.setVideoSurface(surface); + return player; + } + + @Override + protected MediaSource buildSource(HostActivity host, String userAgent, TransferListener mediaTransferListener) { DataSource.Factory manifestDataSourceFactory = new DefaultDataSourceFactory(host, userAgent); DataSource.Factory mediaDataSourceFactory = new DefaultDataSourceFactory(host, userAgent, @@ -803,7 +819,6 @@ public final class DashTest extends ActivityInstrumentationTestCase2 { + + private static final String TAG = "HlsTest"; + private static final String BASE_URL = "https://storage.googleapis.com/" + + "exoplayer-test-media-internal-63834241aced7884c2544af1a3452e01/hls/bipbop/"; + private static final long TIMEOUT_MS = 3 * 60 * 1000; + + public HlsTest() { + super(HostActivity.class); + } + + /** + * Tests playback for two variants with all segments available. + */ + public void testAllSegmentsAvailable() throws IOException { + testPlaybackForPath("bipbop-all-200.m3u8"); + } + + /** + * Tests playback for a single variant with all segments available. + */ + public void testSingleGearAllSegmentsAvailable() throws IOException { + testPlaybackForPath("gear1/prog_index.m3u8"); + } + + /** + * Tests playback for two variants where the first has an unavailable playlist. Playback should + * succeed using the second variant. + */ + public void testGear1PlaylistMissing() throws IOException { + testPlaybackForPath("bipbop-gear1-playlist-404.m3u8"); + } + + /** + * Tests playback for two variants where the second has an unavailable playlist. Playback should + * succeed using the first variant. + */ + public void testGear2PlaylistMissing() throws IOException { + testPlaybackForPath("bipbop-gear2-playlist-404.m3u8"); + } + + /** + * Tests playback for two variants where the first has a missing first segment. Playback should + * succeed using the first segment from the second variant. + */ + public void testGear1Seg1Missing() throws IOException { + testPlaybackForPath("bipbop-gear1-seg1-404.m3u8"); + } + + /** + * Tests playback for two variants where the second has a missing first segment. Playback should + * succeed using the first segment from the first variant. + */ + public void testGear2Seg1Missing() throws IOException { + testPlaybackForPath("bipbop-gear2-seg1-404.m3u8"); + } + + /** + * Tests playback for two variants where the first has a missing second segment. Playback should + * succeed using the second segment from the second variant. + */ + public void testGear1Seg2Missing() throws IOException { + testPlaybackForPath("bipbop-gear1-seg2-404.m3u8"); + } + + /** + * Tests playback for two variants where the second has a missing second segment. Playback should + * succeed using the second segment from the first variant. + */ + public void testGear2Seg2Missing() throws IOException { + testPlaybackForPath("bipbop-gear2-seg2-404.m3u8"); + } + + /** + * Tests playback for two variants where the first has a missing sixth segment. Playback should + * succeed using the sixth segment from the second variant. + */ + public void testGear1Seg6Missing() throws IOException { + testPlaybackForPath("bipbop-gear1-seg6-404.m3u8"); + } + + /** + * Tests playback for two variants where the second has a missing sixth segment. Playback should + * succeed using the sixth segment from the first variant. + */ + public void testGear2Seg6Missing() throws IOException { + testPlaybackForPath("bipbop-gear2-seg6-404.m3u8"); + } + + /** + * Tests playback of a single variant with a missing sixth segment. Playback should fail, however + * should not do so until playback reaches the missing segment at 60 seconds. + */ + public void testSingleGearSeg6Missing() throws IOException { + testPlaybackForPath("gear1/prog_index-seg6-404.m3u8", 60000); + } + + private void testPlaybackForPath(String path) throws IOException { + testPlaybackForPath(path, C.TIME_UNSET); + } + + private void testPlaybackForPath(String path, long expectedFailureTimeMs) throws IOException { + if (Util.SDK_INT < 16) { + // Pass. + return; + } + HlsHostedTest test = new HlsHostedTest(Uri.parse(BASE_URL + path), expectedFailureTimeMs); + getActivity().runTest(test, TIMEOUT_MS); + } + + @TargetApi(16) + private static class HlsHostedTest extends ExoHostedTest { + + private final Uri playlistUri; + + public HlsHostedTest(Uri playlistUri, long expectedFailureTimeMs) { + super(TAG, expectedFailureTimeMs == C.TIME_UNSET + ? ExoHostedTest.EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS : expectedFailureTimeMs, + expectedFailureTimeMs == C.TIME_UNSET); + this.playlistUri = Assertions.checkNotNull(playlistUri); + } + + @Override + public MediaSource buildSource(HostActivity host, String userAgent, + TransferListener mediaTransferListener) { + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(host, userAgent, + mediaTransferListener); + return new HlsMediaSource(playlistUri, dataSourceFactory, null, null); + } + + } + +} diff --git a/playbacktests/src/main/AndroidManifest.xml b/playbacktests/src/main/AndroidManifest.xml index 6a10654af7..8df75b0da1 100644 --- a/playbacktests/src/main/AndroidManifest.xml +++ b/playbacktests/src/main/AndroidManifest.xml @@ -14,30 +14,4 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - + diff --git a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugMediaCodecVideoRenderer.java b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugMediaCodecVideoRenderer.java deleted file mode 100644 index cbc5f35e94..0000000000 --- a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugMediaCodecVideoRenderer.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.playbacktests.util; - -import android.annotation.TargetApi; -import android.content.Context; -import android.os.Handler; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.decoder.DecoderInputBuffer; -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; -import com.google.android.exoplayer2.video.VideoRendererEventListener; - -/** - * Decodes and renders video using {@link MediaCodecVideoRenderer}. Provides buffer timestamp - * assertions. - */ -@TargetApi(16) -public class DebugMediaCodecVideoRenderer extends MediaCodecVideoRenderer { - - private static final int ARRAY_SIZE = 1000; - - private final long[] timestampsList = new long[ARRAY_SIZE]; - - private int startIndex; - private int queueSize; - private int bufferCount; - - public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - int videoScalingMode, long allowedJoiningTimeMs, Handler eventHandler, - VideoRendererEventListener eventListener, int maxDroppedFrameCountToNotify) { - super(context, mediaCodecSelector, videoScalingMode, allowedJoiningTimeMs, null, false, - eventHandler, eventListener, maxDroppedFrameCountToNotify); - startIndex = 0; - queueSize = 0; - } - - @Override - protected void releaseCodec() { - super.releaseCodec(); - clearTimestamps(); - } - - @Override - protected void flushCodec() throws ExoPlaybackException { - super.flushCodec(); - clearTimestamps(); - } - - @Override - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { - insertTimestamp(buffer.timeUs); - maybeShiftTimestampsList(); - } - - @Override - protected void onProcessedOutputBuffer(long presentationTimeUs) { - bufferCount++; - long expectedTimestampUs = dequeueTimestamp(); - if (expectedTimestampUs != presentationTimeUs) { - throw new IllegalStateException("Expected to dequeue video buffer with presentation " - + "timestamp: " + expectedTimestampUs + ". Instead got: " + presentationTimeUs - + " (Processed buffers since last flush: " + bufferCount + ")."); - } - } - - private void clearTimestamps() { - startIndex = 0; - queueSize = 0; - bufferCount = 0; - } - - private void insertTimestamp(long presentationTimeUs) { - for (int i = startIndex + queueSize - 1; i >= startIndex; i--) { - if (presentationTimeUs >= timestampsList[i]) { - timestampsList[i + 1] = presentationTimeUs; - queueSize++; - return; - } - timestampsList[i + 1] = timestampsList[i]; - } - timestampsList[startIndex] = presentationTimeUs; - queueSize++; - } - - private void maybeShiftTimestampsList() { - if (startIndex + queueSize == ARRAY_SIZE) { - System.arraycopy(timestampsList, startIndex, timestampsList, 0, queueSize); - startIndex = 0; - } - } - - private long dequeueTimestamp() { - startIndex++; - queueSize--; - return timestampsList[startIndex - 1]; - } -} diff --git a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugSimpleExoPlayer.java b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugSimpleExoPlayer.java new file mode 100644 index 0000000000..e279bc8ae8 --- /dev/null +++ b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/DebugSimpleExoPlayer.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.playbacktests.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Handler; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; +import com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.util.ArrayList; + +/** + * A debug extension of {@link SimpleExoPlayer}. Provides video buffer timestamp assertions. + */ +@TargetApi(16) +public class DebugSimpleExoPlayer extends SimpleExoPlayer { + + public DebugSimpleExoPlayer(Context context, TrackSelector trackSelector, + LoadControl loadControl, DrmSessionManager drmSessionManager) { + super(context, trackSelector, loadControl, drmSessionManager, + SimpleExoPlayer.EXTENSION_RENDERER_MODE_OFF, 0); + } + + @Override + protected void buildVideoRenderers(Context context, Handler mainHandler, + DrmSessionManager drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, ArrayList out) { + out.add(new DebugMediaCodecVideoRenderer(context, MediaCodecSelector.DEFAULT, + allowedVideoJoiningTimeMs, mainHandler, drmSessionManager, eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + } + + /** + * Decodes and renders video using {@link MediaCodecVideoRenderer}. Provides buffer timestamp + * assertions. + */ + private static class DebugMediaCodecVideoRenderer extends MediaCodecVideoRenderer { + + private static final int ARRAY_SIZE = 1000; + + private final long[] timestampsList = new long[ARRAY_SIZE]; + + private int startIndex; + private int queueSize; + private int bufferCount; + + public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, Handler eventHandler, + DrmSessionManager drmSessionManager, + VideoRendererEventListener eventListener, + int maxDroppedFrameCountToNotify) { + super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, false, + eventHandler, eventListener, maxDroppedFrameCountToNotify); + startIndex = 0; + queueSize = 0; + } + + @Override + protected void releaseCodec() { + super.releaseCodec(); + clearTimestamps(); + } + + @Override + protected void flushCodec() throws ExoPlaybackException { + super.flushCodec(); + clearTimestamps(); + } + + @Override + protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + insertTimestamp(buffer.timeUs); + maybeShiftTimestampsList(); + } + + @Override + protected void onProcessedOutputBuffer(long presentationTimeUs) { + bufferCount++; + long expectedTimestampUs = dequeueTimestamp(); + if (expectedTimestampUs != presentationTimeUs) { + throw new IllegalStateException("Expected to dequeue video buffer with presentation " + + "timestamp: " + expectedTimestampUs + ". Instead got: " + presentationTimeUs + + " (Processed buffers since last flush: " + bufferCount + ")."); + } + } + + private void clearTimestamps() { + startIndex = 0; + queueSize = 0; + bufferCount = 0; + } + + private void insertTimestamp(long presentationTimeUs) { + for (int i = startIndex + queueSize - 1; i >= startIndex; i--) { + if (presentationTimeUs >= timestampsList[i]) { + timestampsList[i + 1] = presentationTimeUs; + queueSize++; + return; + } + timestampsList[i + 1] = timestampsList[i]; + } + timestampsList[startIndex] = presentationTimeUs; + queueSize++; + } + + private void maybeShiftTimestampsList() { + if (startIndex + queueSize == ARRAY_SIZE) { + System.arraycopy(timestampsList, startIndex, timestampsList, 0, queueSize); + startIndex = 0; + } + } + + private long dequeueTimestamp() { + startIndex++; + queueSize--; + return timestampsList[startIndex - 1]; + } + + } + +} diff --git a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/ExoHostedTest.java b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/ExoHostedTest.java index b8ac1eb76c..dfecdd236a 100644 --- a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/ExoHostedTest.java +++ b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/ExoHostedTest.java @@ -30,11 +30,14 @@ import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.AudioTrack; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.playbacktests.util.HostActivity.HostedTest; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.AdaptiveVideoTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; @@ -130,7 +133,7 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); trackSelector = buildTrackSelector(host, bandwidthMeter); String userAgent = "ExoPlayerPlaybackTests"; - DrmSessionManager drmSessionManager = buildDrmSessionManager(userAgent); + DrmSessionManager drmSessionManager = buildDrmSessionManager(userAgent); player = buildExoPlayer(host, surface, trackSelector, drmSessionManager); player.prepare(buildSource(host, Util.getUserAgent(host, userAgent), bandwidthMeter)); player.addListener(this); @@ -185,6 +188,11 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen // Do nothing. } + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + // Do nothing. + } + @Override public final void onPlayerStateChanged(boolean playWhenReady, int playbackState) { Log.d(tag, "state [" + playWhenReady + ", " + playbackState + "]"); @@ -296,7 +304,7 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen // Internal logic - protected DrmSessionManager buildDrmSessionManager(String userAgent) { + protected DrmSessionManager buildDrmSessionManager(String userAgent) { // Do nothing. Interested subclasses may override. return null; } @@ -304,14 +312,16 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen @SuppressWarnings("unused") protected MappingTrackSelector buildTrackSelector(HostActivity host, BandwidthMeter bandwidthMeter) { - return new DefaultTrackSelector(null, new AdaptiveVideoTrackSelection.Factory(bandwidthMeter)); + return new DefaultTrackSelector(new AdaptiveVideoTrackSelection.Factory(bandwidthMeter)); } @SuppressWarnings("unused") protected SimpleExoPlayer buildExoPlayer(HostActivity host, Surface surface, - MappingTrackSelector trackSelector, DrmSessionManager drmSessionManager) { + MappingTrackSelector trackSelector, + DrmSessionManager drmSessionManager) { SimpleExoPlayer player = ExoPlayerFactory.newSimpleInstance(host, trackSelector, - new DefaultLoadControl(), drmSessionManager, false, 0); + new DefaultLoadControl(), drmSessionManager, SimpleExoPlayer.EXTENSION_RENDERER_MODE_OFF, + 0); player.setVideoSurface(surface); return player; } diff --git a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/HostActivity.java b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/HostActivity.java index 2a890b7c7f..9c2ced3a8a 100644 --- a/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/HostActivity.java +++ b/playbacktests/src/main/java/com/google/android/exoplayer2/playbacktests/util/HostActivity.java @@ -232,7 +232,7 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba } @SuppressLint("InlinedApi") - private static final int getWifiLockMode() { + private static int getWifiLockMode() { return Util.SDK_INT < 12 ? WifiManager.WIFI_MODE_FULL : WifiManager.WIFI_MODE_FULL_HIGH_PERF; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 6f4578b694..7f6187f16b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.testutil; import android.app.Instrumentation; +import android.content.Context; import android.test.InstrumentationTestCase; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.Extractor; @@ -65,28 +66,23 @@ public class TestUtil { } } - public static FakeExtractorOutput consumeTestData(Extractor extractor, byte[] data) - throws IOException, InterruptedException { - return consumeTestData(extractor, newExtractorInput(data)); - } - - public static FakeExtractorOutput consumeTestData(Extractor extractor, FakeExtractorInput input) - throws IOException, InterruptedException { - return consumeTestData(extractor, input, false); + public static FakeExtractorOutput consumeTestData(Extractor extractor, FakeExtractorInput input, + long timeUs) throws IOException, InterruptedException { + return consumeTestData(extractor, input, timeUs, false); } public static FakeExtractorOutput consumeTestData(Extractor extractor, FakeExtractorInput input, - boolean retryFromStartIfLive) throws IOException, InterruptedException { + long timeUs, boolean retryFromStartIfLive) throws IOException, InterruptedException { FakeExtractorOutput output = new FakeExtractorOutput(); extractor.init(output); - consumeTestData(extractor, input, output, retryFromStartIfLive); + consumeTestData(extractor, input, timeUs, output, retryFromStartIfLive); return output; } - private static void consumeTestData(Extractor extractor, FakeExtractorInput input, + private static void consumeTestData(Extractor extractor, FakeExtractorInput input, long timeUs, FakeExtractorOutput output, boolean retryFromStartIfLive) throws IOException, InterruptedException { - extractor.seek(input.getPosition()); + extractor.seek(input.getPosition(), timeUs); PositionHolder seekPositionHolder = new PositionHolder(); int readResult = Extractor.RESULT_CONTINUE; while (readResult != Extractor.RESULT_END_OF_INPUT) { @@ -113,7 +109,7 @@ public class TestUtil { for (int i = 0; i < output.numberOfTracks; i++) { output.trackOutputs.valueAt(i).clear(); } - extractor.seek(0); + extractor.seek(0, 0); } } } @@ -276,7 +272,7 @@ public class TestUtil { Assert.assertTrue(sniffTestData(extractor, input)); input.resetPeekPosition(); - FakeExtractorOutput extractorOutput = consumeTestData(extractor, input, true); + FakeExtractorOutput extractorOutput = consumeTestData(extractor, input, 0, true); if (simulateUnknownLength && assetExists(instrumentation, sampleFile + UNKNOWN_LENGTH_EXTENSION)) { @@ -296,7 +292,7 @@ public class TestUtil { extractorOutput.trackOutputs.valueAt(i).clear(); } - consumeTestData(extractor, input, extractorOutput, false); + consumeTestData(extractor, input, timeUs, extractorOutput, false); extractorOutput.assertOutput(instrumentation, sampleFile + '.' + j + DUMP_EXTENSION); } } @@ -313,4 +309,12 @@ public class TestUtil { fileOrDirectory.delete(); } + /** Creates an empty folder in the application specific cache directory. */ + public static File createTempFolder(Context context) throws IOException { + File tempFolder = File.createTempFile("ExoPlayerTest", null, context.getCacheDir()); + Assert.assertTrue(tempFolder.delete()); + Assert.assertTrue(tempFolder.mkdir()); + return tempFolder; + } + }