diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8ddbe4068c..ce376bfc07 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -58,7 +58,7 @@ this version. * 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)). + is attached ([#2077](https://github.com/google/ExoPlayer/issues/2077)). * OGG: Fix playback of short OGG files ([#1976](https://github.com/google/ExoPlayer/issues/1976)). * MP4: Support `.mp3` tracks diff --git a/demo/assets/ic_launcher.svg b/demo/assets/ic_launcher.svg deleted file mode 100644 index 5486b27e29..0000000000 --- a/demo/assets/ic_launcher.svg +++ /dev/null @@ -1,660 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demo/build.gradle b/demo/build.gradle index 27180682fa..007dc70590 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -24,24 +24,19 @@ android { buildTypes { release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + shrinkResources true + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt') } debug { jniDebuggable = true - debuggable = true } } - lintOptions { - abortOnError false - } - productFlavors { noExtensions withExtensions } - } dependencies { diff --git a/demo/src/main/assets/media.exolist.json b/demo/src/main/assets/media.exolist.json index b5800c6de7..081de00312 100644 --- a/demo/src/main/assets/media.exolist.json +++ b/demo/src/main/assets/media.exolist.json @@ -229,30 +229,6 @@ "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample (WebM, VP9 with altref)", - "uri": "https://storage.googleapis.com/widevine_test/vp9/sintel_1080p_vp9_altref_subsample/sintel_1080p_vp9_altref_subsample.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://widevine-proxy.appspot.com/proxy" - }, - { - "name": "WV: Secure Fullsample (WebM, VP9 with altref)", - "uri": "https://storage.googleapis.com/widevine_test/vp9/sintel_1080p_vp9_altref_fullsample/sintel_1080p_vp9_altref_fullsample.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://widevine-proxy.appspot.com/proxy" - }, - { - "name": "WV: Secure Subsample (WebM, VP9 without altref)", - "uri": "https://storage.googleapis.com/widevine_test/vp9/sintel_1080p_vp9_noaltref_subsample/sintel_1080p_vp9_noaltref_subsample.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://widevine-proxy.appspot.com/proxy" - }, - { - "name": "WV: Secure Fullsample (WebM, VP9 without altref)", - "uri": "https://storage.googleapis.com/widevine_test/vp9/sintel_1080p_vp9_noaltref_fullsample/sintel_1080p_vp9_noaltref_fullsample.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://widevine-proxy.appspot.com/proxy" } ] }, 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 5ad28f9e72..edc268ddb9 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 @@ -26,16 +26,17 @@ import com.google.android.exoplayer2.RendererCapabilities; 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.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataRenderer; +import com.google.android.exoplayer2.metadata.emsg.EventMessage; 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; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; -import com.google.android.exoplayer2.metadata.id3.TxxxFrame; +import com.google.android.exoplayer2.metadata.id3.UrlLinkFrame; import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.TrackGroup; @@ -55,7 +56,7 @@ import java.util.Locale; */ /* package */ final class EventLogger implements ExoPlayer.EventListener, AudioRendererEventListener, VideoRendererEventListener, AdaptiveMediaSourceEventListener, - ExtractorMediaSource.EventListener, StreamingDrmSessionManager.EventListener, + ExtractorMediaSource.EventListener, DefaultDrmSessionManager.EventListener, MetadataRenderer.Output { private static final String TAG = "EventLogger"; @@ -153,7 +154,7 @@ import java.util.Locale; String formatSupport = getFormatSupportString( mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); Log.d(TAG, " " + status + " Track:" + trackIndex + ", " - + getFormatString(trackGroup.getFormat(trackIndex)) + + Format.toLogString(trackGroup.getFormat(trackIndex)) + ", supported=" + formatSupport); } Log.d(TAG, " ]"); @@ -185,7 +186,7 @@ import java.util.Locale; String formatSupport = getFormatSupportString( RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); Log.d(TAG, " " + status + " Track:" + trackIndex + ", " - + getFormatString(trackGroup.getFormat(trackIndex)) + + Format.toLogString(trackGroup.getFormat(trackIndex)) + ", supported=" + formatSupport); } Log.d(TAG, " ]"); @@ -224,7 +225,7 @@ import java.util.Locale; @Override public void onAudioInputFormatChanged(Format format) { - Log.d(TAG, "audioFormatChanged [" + getSessionTimeString() + ", " + getFormatString(format) + Log.d(TAG, "audioFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) + "]"); } @@ -254,7 +255,7 @@ import java.util.Locale; @Override public void onVideoInputFormatChanged(Format format) { - Log.d(TAG, "videoFormatChanged [" + getSessionTimeString() + ", " + getFormatString(format) + Log.d(TAG, "videoFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) + "]"); } @@ -279,13 +280,23 @@ import java.util.Locale; // Do nothing. } - // StreamingDrmSessionManager.EventListener + // DefaultDrmSessionManager.EventListener @Override public void onDrmSessionManagerError(Exception e) { printInternalError("drmSessionManagerError", e); } + @Override + public void onDrmKeysRestored() { + Log.d(TAG, "drmKeysRestored [" + getSessionTimeString() + "]"); + } + + @Override + public void onDrmKeysRemoved() { + Log.d(TAG, "drmKeysRemoved [" + getSessionTimeString() + "]"); + } + @Override public void onDrmKeysLoaded() { Log.d(TAG, "drmKeysLoaded [" + getSessionTimeString() + "]"); @@ -349,10 +360,13 @@ import java.util.Locale; 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)); + if (entry instanceof TextInformationFrame) { + TextInformationFrame textInformationFrame = (TextInformationFrame) entry; + Log.d(TAG, prefix + String.format("%s: value=%s", textInformationFrame.id, + textInformationFrame.value)); + } else if (entry instanceof UrlLinkFrame) { + UrlLinkFrame urlLinkFrame = (UrlLinkFrame) entry; + Log.d(TAG, prefix + String.format("%s: url=%s", urlLinkFrame.id, urlLinkFrame.url)); } else if (entry instanceof PrivFrame) { PrivFrame privFrame = (PrivFrame) entry; Log.d(TAG, prefix + String.format("%s: owner=%s", privFrame.id, privFrame.owner)); @@ -364,17 +378,17 @@ import java.util.Locale; 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, + 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)); + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + Log.d(TAG, prefix + String.format("EMSG: scheme=%s, id=%d, value=%s", + eventMessage.schemeIdUri, eventMessage.id, eventMessage.value)); } } } @@ -433,33 +447,6 @@ import java.util.Locale; } } - private static String getFormatString(Format format) { - if (format == null) { - return "null"; - } - StringBuilder builder = new StringBuilder(); - builder.append("id=").append(format.id).append(", mimeType=").append(format.sampleMimeType); - if (format.bitrate != Format.NO_VALUE) { - builder.append(", bitrate=").append(format.bitrate); - } - if (format.width != Format.NO_VALUE && format.height != Format.NO_VALUE) { - builder.append(", res=").append(format.width).append("x").append(format.height); - } - if (format.frameRate != Format.NO_VALUE) { - builder.append(", fps=").append(format.frameRate); - } - if (format.channelCount != Format.NO_VALUE) { - builder.append(", channels=").append(format.channelCount); - } - if (format.sampleRate != Format.NO_VALUE) { - builder.append(", sample_rate=").append(format.sampleRate); - } - if (format.language != null) { - builder.append(", language=").append(format.language); - } - return builder.toString(); - } - private static String getTrackStatusString(TrackSelection selection, TrackGroup group, int trackIndex) { return getTrackStatusString(selection != null && selection.getTrackGroup() == group 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 243fcadce0..e61a9ed130 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 @@ -36,15 +36,16 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.drm.FrameworkMediaDrm; import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.google.android.exoplayer2.drm.StreamingDrmSessionManager; import com.google.android.exoplayer2.drm.UnsupportedDrmException; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; @@ -100,7 +101,6 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } private Handler mainHandler; - private Timeline.Window window; private EventLogger eventLogger; private SimpleExoPlayerView simpleExoPlayerView; private LinearLayout debugRootView; @@ -115,9 +115,8 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay private boolean playerNeedsSource; private boolean shouldAutoPlay; - private boolean isTimelineStatic; - private int playerWindow; - private long playerPosition; + private int resumeWindow; + private long resumePosition; // Activity lifecycle @@ -125,9 +124,9 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); shouldAutoPlay = true; + clearResumePosition(); mediaDataSourceFactory = buildDataSourceFactory(true); mainHandler = new Handler(); - window = new Timeline.Window(); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); } @@ -148,7 +147,8 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay @Override public void onNewIntent(Intent intent) { releasePlayer(); - isTimelineStatic = false; + shouldAutoPlay = true; + clearResumePosition(); setIntent(intent); } @@ -264,7 +264,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay @SimpleExoPlayer.ExtensionRendererMode int extensionRendererMode = ((DemoApplication) getApplication()).useExtensionRenderers() ? (preferExtensionDecoders ? SimpleExoPlayer.EXTENSION_RENDERER_MODE_PREFER - : SimpleExoPlayer.EXTENSION_RENDERER_MODE_ON) + : SimpleExoPlayer.EXTENSION_RENDERER_MODE_ON) : SimpleExoPlayer.EXTENSION_RENDERER_MODE_OFF; TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveVideoTrackSelection.Factory(BANDWIDTH_METER); @@ -278,16 +278,9 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay player.addListener(eventLogger); player.setAudioDebugListener(eventLogger); player.setVideoDebugListener(eventLogger); - player.setId3Output(eventLogger); + player.setMetadataOutput(eventLogger); simpleExoPlayerView.setPlayer(player); - if (isTimelineStatic) { - if (playerPosition == C.TIME_UNSET) { - player.seekToDefaultPosition(playerWindow); - } else { - player.seekTo(playerWindow, playerPosition); - } - } player.setPlayWhenReady(shouldAutoPlay); debugViewHelper = new DebugTextViewHelper(player, debugTextView); debugViewHelper.start(); @@ -324,7 +317,8 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } MediaSource mediaSource = mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources); - player.prepare(mediaSource, !isTimelineStatic, !isTimelineStatic); + player.seekTo(resumeWindow, resumePosition); + player.prepare(mediaSource, false, false); playerNeedsSource = false; updateButtonVisibilities(); } @@ -358,7 +352,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(licenseUrl, buildHttpDataSourceFactory(false), keyRequestProperties); - return new StreamingDrmSessionManager<>(uuid, + return new DefaultDrmSessionManager<>(uuid, FrameworkMediaDrm.newInstance(uuid), drmCallback, null, mainHandler, eventLogger); } @@ -367,12 +361,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay debugViewHelper.stop(); debugViewHelper = null; shouldAutoPlay = player.getPlayWhenReady(); - playerWindow = player.getCurrentWindowIndex(); - playerPosition = C.TIME_UNSET; - Timeline timeline = player.getCurrentTimeline(); - if (!timeline.isEmpty() && timeline.getWindow(playerWindow, window).isSeekable) { - playerPosition = player.getCurrentPosition(); - } + updateResumePosition(); player.release(); player = null; trackSelector = null; @@ -381,6 +370,17 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay } } + private void updateResumePosition() { + resumeWindow = player.getCurrentWindowIndex(); + resumePosition = player.isCurrentWindowSeekable() ? Math.max(0, player.getCurrentPosition()) + : C.TIME_UNSET; + } + + private void clearResumePosition() { + resumeWindow = 0; + resumePosition = C.TIME_UNSET; + } + /** * Returns a new DataSource factory. * @@ -427,8 +427,7 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay @Override public void onTimelineChanged(Timeline timeline, Object manifest) { - isTimelineStatic = !timeline.isEmpty() - && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic; + // Do nothing. } @Override @@ -460,6 +459,11 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay showToast(errorString); } playerNeedsSource = true; + if (isBehindLiveWindow(e)) { + clearResumePosition(); + } else { + updateResumePosition(); + } updateButtonVisibilities(); showControls(); } @@ -535,4 +539,18 @@ public class PlayerActivity extends Activity implements OnClickListener, ExoPlay Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); } + private static boolean isBehindLiveWindow(ExoPlaybackException e) { + if (e.type != ExoPlaybackException.TYPE_SOURCE) { + return false; + } + Throwable cause = e.getSourceException(); + while (cause != null) { + if (cause instanceof BehindLiveWindowException) { + return true; + } + cause = cause.getCause(); + } + return false; + } + } diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index 3c9a36c891..a245133937 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -23,17 +23,6 @@ android { testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false - } - sourceSets.main { jniLibs.srcDirs = ['jniLibs'] } 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 7efc542dd0..31def44d36 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 @@ -57,8 +57,8 @@ import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicInteger; import org.chromium.net.CronetEngine; +import org.chromium.net.NetworkException; 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; @@ -99,7 +99,7 @@ public final class CronetDataSourceTest { @Mock private Executor mockExecutor; @Mock - private UrlRequestException mockUrlRequestException; + private NetworkException mockNetworkException; @Mock private CronetEngine mockCronetEngine; private CronetDataSource dataSourceUnderTest; @@ -172,7 +172,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.onFailed( mockUrlRequest, testUrlResponseInfo, - mockUrlRequestException); + mockNetworkException); dataSourceUnderTest.onResponseStarted( mockUrlRequest2, testUrlResponseInfo); @@ -245,8 +245,8 @@ public final class CronetDataSourceTest { @Test public void testRequestOpenFailDueToDnsFailure() { mockResponseStartFailure(); - when(mockUrlRequestException.getErrorCode()).thenReturn( - UrlRequestException.ERROR_HOSTNAME_NOT_RESOLVED); + when(mockNetworkException.getErrorCode()).thenReturn( + NetworkException.ERROR_HOSTNAME_NOT_RESOLVED); try { dataSourceUnderTest.open(testDataSpec); @@ -728,7 +728,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.onFailed( mockUrlRequest, createUrlResponseInfo(500), // statusCode - mockUrlRequestException); + mockNetworkException); return null; } }).when(mockUrlRequest).start(); @@ -764,7 +764,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.onFailed( mockUrlRequest, createUrlResponseInfo(500), // statusCode - mockUrlRequestException); + mockNetworkException); return null; } }).when(mockUrlRequest).read(any(ByteBuffer.class)); 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 83f46bd488..f6202c6e1e 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 @@ -40,9 +40,10 @@ import java.util.concurrent.Executor; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.chromium.net.CronetEngine; +import org.chromium.net.CronetException; +import org.chromium.net.NetworkException; import org.chromium.net.UrlRequest; import org.chromium.net.UrlRequest.Status; -import org.chromium.net.UrlRequestException; import org.chromium.net.UrlResponseInfo; /** @@ -400,12 +401,17 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou @Override public synchronized void onFailed(UrlRequest request, UrlResponseInfo info, - UrlRequestException error) { + CronetException error) { if (request != currentUrlRequest) { return; } - exception = error.getErrorCode() == UrlRequestException.ERROR_HOSTNAME_NOT_RESOLVED - ? new UnknownHostException() : error; + if (error instanceof NetworkException + && ((NetworkException) error).getErrorCode() + == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) { + exception = new UnknownHostException(); + } else { + exception = error; + } operation.open(); } diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java index 0f94dad158..3df901ce59 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.cronet; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Predicate; @@ -25,7 +26,7 @@ import org.chromium.net.CronetEngine; /** * A {@link Factory} that produces {@link CronetDataSource}. */ -public final class CronetDataSourceFactory implements Factory { +public final class CronetDataSourceFactory extends BaseFactory { /** * The default connection timeout, in milliseconds. @@ -67,7 +68,7 @@ public final class CronetDataSourceFactory implements Factory { } @Override - public CronetDataSource createDataSource() { + protected CronetDataSource createDataSourceInternal() { return new CronetDataSource(cronetEngine, executor, contentTypePredicate, transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects); } diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index 579c663fe2..e0f6d900a0 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -20,17 +20,7 @@ android { defaultConfig { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false + consumerProguardFiles 'proguard-rules.txt' } sourceSets.main { diff --git a/extensions/ffmpeg/src/main/proguard.cfg b/extensions/ffmpeg/proguard-rules.txt similarity index 100% rename from extensions/ffmpeg/src/main/proguard.cfg rename to extensions/ffmpeg/proguard-rules.txt 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 1a70310a8d..0aac601045 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 @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.ext.ffmpeg; import android.os.Handler; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener; @@ -60,7 +61,7 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - public int supportsFormat(Format format) { + protected int supportsFormatInternal(Format format) { if (!FfmpegLibrary.isAvailable()) { return FORMAT_UNSUPPORTED_TYPE; } @@ -69,6 +70,11 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { : MimeTypes.isAudio(mimeType) ? FORMAT_UNSUPPORTED_SUBTYPE : FORMAT_UNSUPPORTED_TYPE; } + @Override + public final int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException { + return ADAPTIVE_NOT_SEAMLESS; + } + @Override protected FfmpegDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) throws FfmpegDecoderException { diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index a40a1adead..7f1a790dad 100644 --- a/extensions/flac/build.gradle +++ b/extensions/flac/build.gradle @@ -20,17 +20,7 @@ android { defaultConfig { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false + consumerProguardFiles 'proguard-rules.txt' } sourceSets.main { diff --git a/extensions/flac/src/main/proguard.cfg b/extensions/flac/proguard-rules.txt similarity index 100% rename from extensions/flac/src/main/proguard.cfg rename to extensions/flac/proguard-rules.txt 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 954a090ee9..eb7206c9cf 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 @@ -56,7 +56,7 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - public int supportsFormat(Format format) { + protected int supportsFormatInternal(Format format) { return FlacLibrary.isAvailable() && MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; } diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 442f0f78dc..bbf69c60e4 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -22,17 +22,6 @@ android { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false - } } dependencies { 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 8577d33781..90a4728933 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 @@ -261,7 +261,7 @@ public class OkHttpDataSource implements HttpDataSource { private Request makeRequest(DataSpec dataSpec) { long position = dataSpec.position; long length = dataSpec.length; - boolean allowGzip = (dataSpec.flags & DataSpec.FLAG_ALLOW_GZIP) != 0; + boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); HttpUrl url = HttpUrl.parse(dataSpec.uri.toString()); Request.Builder builder = new Request.Builder().url(url); 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 33f204a6f3..8cbe295fa4 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.okhttp; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; import com.google.android.exoplayer2.upstream.TransferListener; import okhttp3.CacheControl; @@ -24,7 +25,7 @@ import okhttp3.Call; /** * A {@link Factory} that produces {@link OkHttpDataSource}. */ -public final class OkHttpDataSourceFactory implements Factory { +public final class OkHttpDataSourceFactory extends BaseFactory { private final Call.Factory callFactory; private final String userAgent; @@ -58,7 +59,7 @@ public final class OkHttpDataSourceFactory implements Factory { } @Override - public OkHttpDataSource createDataSource() { + protected OkHttpDataSource createDataSourceInternal() { return new OkHttpDataSource(callFactory, userAgent, null, listener, cacheControl); } diff --git a/extensions/opus/build.gradle b/extensions/opus/build.gradle index 3bc06ddaf4..d354654c14 100644 --- a/extensions/opus/build.gradle +++ b/extensions/opus/build.gradle @@ -20,17 +20,7 @@ android { defaultConfig { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false + consumerProguardFiles 'proguard-rules.txt' } sourceSets.main { diff --git a/extensions/opus/src/main/proguard.cfg b/extensions/opus/proguard-rules.txt similarity index 100% rename from extensions/opus/src/main/proguard.cfg rename to extensions/opus/proguard-rules.txt 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 2dd2697aab..1850e68229 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 @@ -72,7 +72,7 @@ public final class LibopusAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - public int supportsFormat(Format format) { + protected int supportsFormatInternal(Format format) { return OpusLibrary.isAvailable() && MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; } diff --git a/extensions/vp9/build.gradle b/extensions/vp9/build.gradle index 3bc06ddaf4..d354654c14 100644 --- a/extensions/vp9/build.gradle +++ b/extensions/vp9/build.gradle @@ -20,17 +20,7 @@ android { defaultConfig { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false + consumerProguardFiles 'proguard-rules.txt' } sourceSets.main { diff --git a/extensions/vp9/src/main/proguard.cfg b/extensions/vp9/proguard-rules.txt similarity index 100% rename from extensions/vp9/src/main/proguard.cfg rename to extensions/vp9/proguard-rules.txt diff --git a/library/build.gradle b/library/build.gradle index 5ec947d0eb..0d4bbd0256 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,5 +1,3 @@ -import com.android.builder.core.BuilderConstants - // Copyright (C) 2016 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,6 +11,8 @@ import com.android.builder.core.BuilderConstants // 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. +import com.android.builder.core.BuilderConstants + apply plugin: 'com.android.library' apply plugin: 'bintray-release' @@ -28,13 +28,10 @@ android { // greater. minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion + consumerProguardFiles 'proguard-rules.txt' } buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } // Re-enable test coverage when the following issue is fixed: // https://code.google.com/p/android/issues/detail?id=226070 // debug { @@ -42,10 +39,6 @@ android { // } } - lintOptions { - abortOnError false - } - sourceSets { androidTest { java.srcDirs += "../testutils/src/main/java/" diff --git a/library/proguard-rules.txt b/library/proguard-rules.txt new file mode 100644 index 0000000000..75f2d095be --- /dev/null +++ b/library/proguard-rules.txt @@ -0,0 +1,7 @@ +# Accessed via reflection in SubtitleDecoderFactory.DEFAULT +-keepclassmembers class com.google.android.exoplayer2.text.cea.Cea608Decoder { + public (java.lang.String, int); +} +-keepclassmembers class com.google.android.exoplayer2.text.cea.Cea708Decoder { + public (int); +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java index 0f6f3b07b1..1197139b01 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -21,7 +21,6 @@ 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; @@ -29,6 +28,7 @@ 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.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; import java.io.IOException; import java.util.ArrayList; @@ -49,16 +49,11 @@ public final class ExoPlayerTest extends TestCase { */ private static final int TIMEOUT_MS = 10000; - /** - * Tests playback of a source that exposes a single period. - */ - 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), null, format); - playerWrapper.blockUntilEnded(TIMEOUT_MS); - } + private static final Format TEST_VIDEO_FORMAT = Format.createVideoSampleFormat(null, + MimeTypes.VIDEO_H264, null, Format.NO_VALUE, Format.NO_VALUE, 1280, 720, Format.NO_VALUE, + null, null); + private static final Format TEST_AUDIO_FORMAT = Format.createAudioSampleFormat(null, + MimeTypes.AUDIO_AAC, null, Format.NO_VALUE, Format.NO_VALUE, 2, 44100, null, null, 0, null); /** * Tests playback of a source that exposes an empty timeline. Playback is expected to end without @@ -66,8 +61,100 @@ public final class ExoPlayerTest extends TestCase { */ public void testPlayEmptyTimeline() throws Exception { PlayerWrapper playerWrapper = new PlayerWrapper(); - playerWrapper.setup(Timeline.EMPTY, null, null); + Timeline timeline = Timeline.EMPTY; + MediaSource mediaSource = new FakeMediaSource(timeline, null); + FakeRenderer renderer = new FakeRenderer(null); + playerWrapper.setup(mediaSource, renderer); playerWrapper.blockUntilEnded(TIMEOUT_MS); + assertEquals(0, playerWrapper.positionDiscontinuityCount); + assertEquals(0, renderer.formatReadCount); + assertEquals(0, renderer.bufferReadCount); + assertFalse(renderer.isEnded); + assertEquals(timeline, playerWrapper.timeline); + assertNull(playerWrapper.manifest); + } + + /** + * Tests playback of a source that exposes a single period. + */ + public void testPlaySinglePeriodTimeline() throws Exception { + PlayerWrapper playerWrapper = new PlayerWrapper(); + Timeline timeline = new FakeTimeline(new TimelineWindowDefinition(false, false, 0)); + Object manifest = new Object(); + MediaSource mediaSource = new FakeMediaSource(timeline, manifest, TEST_VIDEO_FORMAT); + FakeRenderer renderer = new FakeRenderer(TEST_VIDEO_FORMAT); + playerWrapper.setup(mediaSource, renderer); + playerWrapper.blockUntilEnded(TIMEOUT_MS); + assertEquals(0, playerWrapper.positionDiscontinuityCount); + assertEquals(1, renderer.formatReadCount); + assertEquals(1, renderer.bufferReadCount); + assertTrue(renderer.isEnded); + assertEquals(timeline, playerWrapper.timeline); + assertEquals(manifest, playerWrapper.manifest); + assertEquals(new TrackGroupArray(new TrackGroup(TEST_VIDEO_FORMAT)), playerWrapper.trackGroups); + } + + /** + * Tests playback of a source that exposes three periods. + */ + public void testPlayMultiPeriodTimeline() throws Exception { + PlayerWrapper playerWrapper = new PlayerWrapper(); + Timeline timeline = new FakeTimeline( + new TimelineWindowDefinition(false, false, 0), + new TimelineWindowDefinition(false, false, 0), + new TimelineWindowDefinition(false, false, 0)); + MediaSource mediaSource = new FakeMediaSource(timeline, null, TEST_VIDEO_FORMAT); + FakeRenderer renderer = new FakeRenderer(TEST_VIDEO_FORMAT); + playerWrapper.setup(mediaSource, renderer); + playerWrapper.blockUntilEnded(TIMEOUT_MS); + assertEquals(2, playerWrapper.positionDiscontinuityCount); + assertEquals(3, renderer.formatReadCount); + assertEquals(1, renderer.bufferReadCount); + assertTrue(renderer.isEnded); + assertEquals(timeline, playerWrapper.timeline); + assertNull(playerWrapper.manifest); + } + + /** + * Tests that the player does not unnecessarily reset renderers when playing a multi-period + * source. + */ + public void testReadAheadToEndDoesNotResetRenderer() throws Exception { + final PlayerWrapper playerWrapper = new PlayerWrapper(); + Timeline timeline = new FakeTimeline( + new TimelineWindowDefinition(false, false, 10), + new TimelineWindowDefinition(false, false, 10), + new TimelineWindowDefinition(false, false, 10)); + MediaSource mediaSource = new FakeMediaSource(timeline, null, TEST_VIDEO_FORMAT, + TEST_AUDIO_FORMAT); + + FakeRenderer videoRenderer = new FakeRenderer(TEST_VIDEO_FORMAT); + FakeMediaClockRenderer audioRenderer = new FakeMediaClockRenderer(TEST_AUDIO_FORMAT) { + + @Override + public long getPositionUs() { + // Simulate the playback position lagging behind the reading position: the renderer media + // clock position will be the start of the timeline until the stream is set to be final, at + // which point it jumps to the end of the timeline allowing the playing period to advance. + // TODO: Avoid hard-coding ExoPlayerImplInternal.RENDERER_TIMESTAMP_OFFSET_US. + return isCurrentStreamFinal() ? 60000030 : 60000000; + } + + @Override + public boolean isEnded() { + // Allow playback to end once the final period is playing. + return playerWrapper.positionDiscontinuityCount == 2; + } + + }; + playerWrapper.setup(mediaSource, videoRenderer, audioRenderer); + playerWrapper.blockUntilEnded(TIMEOUT_MS); + assertEquals(2, playerWrapper.positionDiscontinuityCount); + assertEquals(1, audioRenderer.positionResetCount); + assertTrue(videoRenderer.isEnded); + assertTrue(audioRenderer.isEnded); + assertEquals(timeline, playerWrapper.timeline); + assertNull(playerWrapper.manifest); } /** @@ -79,12 +166,15 @@ public final class ExoPlayerTest extends TestCase { private final HandlerThread playerThread; private final Handler handler; - private Timeline expectedTimeline; - private Object expectedManifest; - private Format expectedFormat; private ExoPlayer player; + private Timeline timeline; + private Object manifest; + private TrackGroupArray trackGroups; private Exception exception; + // Written only on the main thread. + private volatile int positionDiscontinuityCount; + public PlayerWrapper() { endedCountDownLatch = new CountDownLatch(1); playerThread = new HandlerThread("ExoPlayerTest thread"); @@ -105,20 +195,15 @@ public final class ExoPlayerTest extends TestCase { } } - public void setup(final Timeline timeline, final Object manifest, final Format format) { - expectedTimeline = timeline; - expectedManifest = manifest; - expectedFormat = format; + public void setup(final MediaSource mediaSource, final Renderer... renderers) { handler.post(new Runnable() { @Override public void run() { try { - Renderer fakeRenderer = new FakeVideoRenderer(expectedFormat); - player = ExoPlayerFactory.newInstance(new Renderer[] {fakeRenderer}, - new DefaultTrackSelector()); + player = ExoPlayerFactory.newInstance(renderers, new DefaultTrackSelector()); player.addListener(PlayerWrapper.this); player.setPlayWhenReady(true); - player.prepare(new FakeMediaSource(timeline, manifest, format)); + player.prepare(mediaSource); } catch (Exception e) { handleError(e); } @@ -167,14 +252,13 @@ public final class ExoPlayerTest extends TestCase { @Override public void onTimelineChanged(Timeline timeline, Object manifest) { - assertEquals(expectedTimeline, timeline); - assertEquals(expectedManifest, manifest); + this.timeline = timeline; + this.manifest = manifest; } @Override - public void onTracksChanged(TrackGroupArray trackGroups, - TrackSelectionArray trackSelections) { - assertEquals(new TrackGroupArray(new TrackGroup(expectedFormat)), trackGroups); + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + this.trackGroups = trackGroups; } @Override @@ -182,10 +266,69 @@ public final class ExoPlayerTest extends TestCase { handleError(exception); } + @SuppressWarnings("NonAtomicVolatileUpdate") @Override public void onPositionDiscontinuity() { - // Should never happen. - handleError(new IllegalStateException("Received position discontinuity")); + positionDiscontinuityCount++; + } + + } + + private static final class TimelineWindowDefinition { + + public final boolean isSeekable; + public final boolean isDynamic; + public final long durationUs; + + public TimelineWindowDefinition(boolean isSeekable, boolean isDynamic, long durationUs) { + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.durationUs = durationUs; + } + + } + + private static final class FakeTimeline extends Timeline { + + private final TimelineWindowDefinition[] windowDefinitions; + + public FakeTimeline(TimelineWindowDefinition... windowDefinitions) { + this.windowDefinitions = windowDefinitions; + } + + @Override + public int getWindowCount() { + return windowDefinitions.length; + } + + @Override + public Window getWindow(int windowIndex, Window window, boolean setIds, + long defaultPositionProjectionUs) { + TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; + Object id = setIds ? windowIndex : null; + return window.set(id, C.TIME_UNSET, C.TIME_UNSET, windowDefinition.isSeekable, + windowDefinition.isDynamic, 0, windowDefinition.durationUs, windowIndex, windowIndex, 0); + } + + @Override + public int getPeriodCount() { + return windowDefinitions.length; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + TimelineWindowDefinition windowDefinition = windowDefinitions[periodIndex]; + Object id = setIds ? periodIndex : null; + return period.set(id, id, periodIndex, windowDefinition.durationUs, 0); + } + + @Override + public int getIndexOfPeriod(Object uid) { + if (!(uid instanceof Integer)) { + return C.INDEX_UNSET; + } + int index = (Integer) uid; + return index >= 0 && index < windowDefinitions.length ? index : C.INDEX_UNSET; } } @@ -198,16 +341,20 @@ public final class ExoPlayerTest extends TestCase { private final Timeline timeline; private final Object manifest; - private final Format format; + private final TrackGroupArray trackGroupArray; private final ArrayList activeMediaPeriods; private boolean preparedSource; private boolean releasedSource; - public FakeMediaSource(Timeline timeline, Object manifest, Format format) { + public FakeMediaSource(Timeline timeline, Object manifest, Format... formats) { this.timeline = timeline; this.manifest = manifest; - this.format = format; + TrackGroup[] trackGroups = new TrackGroup[formats.length]; + for (int i = 0; i < formats.length; i++) { + trackGroups[i] = new TrackGroup(formats[i]); + } + trackGroupArray = new TrackGroupArray(trackGroups); activeMediaPeriods = new ArrayList<>(); } @@ -228,9 +375,8 @@ public final class ExoPlayerTest extends TestCase { Assertions.checkIndex(index, 0, timeline.getPeriodCount()); assertTrue(preparedSource); assertFalse(releasedSource); - assertEquals(0, index); assertEquals(0, positionUs); - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(format); + FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray); activeMediaPeriods.add(mediaPeriod); return mediaPeriod; } @@ -239,8 +385,9 @@ public final class ExoPlayerTest extends TestCase { public void releasePeriod(MediaPeriod mediaPeriod) { assertTrue(preparedSource); assertFalse(releasedSource); - assertTrue(activeMediaPeriods.remove(mediaPeriod)); - ((FakeMediaPeriod) mediaPeriod).release(); + FakeMediaPeriod fakeMediaPeriod = (FakeMediaPeriod) mediaPeriod; + assertTrue(activeMediaPeriods.remove(fakeMediaPeriod)); + fakeMediaPeriod.release(); } @Override @@ -259,12 +406,12 @@ public final class ExoPlayerTest extends TestCase { */ private static final class FakeMediaPeriod implements MediaPeriod { - private final TrackGroup trackGroup; + private final TrackGroupArray trackGroupArray; private boolean preparedPeriod; - public FakeMediaPeriod(Format format) { - trackGroup = new TrackGroup(format); + public FakeMediaPeriod(TrackGroupArray trackGroupArray) { + this.trackGroupArray = trackGroupArray; } public void release() { @@ -286,26 +433,29 @@ public final class ExoPlayerTest extends TestCase { @Override public TrackGroupArray getTrackGroups() { assertTrue(preparedPeriod); - return new TrackGroupArray(trackGroup); + return trackGroupArray; } @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; + int rendererCount = selections.length; + for (int i = 0; i < rendererCount; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + streams[i] = 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; + for (int i = 0; i < rendererCount; i++) { + if (streams[i] == null && selections[i] != null) { + TrackSelection selection = selections[i]; + assertEquals(1, selection.length()); + assertEquals(0, selection.getIndexInTrackGroup(0)); + TrackGroup trackGroup = selection.getTrackGroup(); + assertTrue(trackGroupArray.indexOf(trackGroup) != C.INDEX_UNSET); + streams[i] = new FakeSampleStream(trackGroup.getFormat(0)); + streamResetFlags[i] = true; + } } return 0; } @@ -332,7 +482,7 @@ public final class ExoPlayerTest extends TestCase { @Override public long getNextLoadPositionUs() { assertTrue(preparedPeriod); - return 0; + return C.TIME_END_OF_SOURCE; } @Override @@ -352,7 +502,6 @@ public final class ExoPlayerTest extends TestCase { private final Format format; private boolean readFormat; - private boolean readEndOfStream; public FakeSampleStream(Format format) { this.format = format; @@ -365,15 +514,14 @@ public final class ExoPlayerTest extends TestCase { @Override public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer) { - Assertions.checkState(!readEndOfStream); - if (readFormat) { + if (buffer == null || !readFormat) { + formatHolder.format = format; + readFormat = true; + return C.RESULT_FORMAT_READ; + } else { 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 @@ -389,20 +537,30 @@ public final class ExoPlayerTest extends TestCase { } /** - * 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. + * Fake {@link Renderer} that supports any format with the matching MIME type. The renderer + * verifies that it reads a given {@link Format}. */ - private static final class FakeVideoRenderer extends BaseRenderer { + private static class FakeRenderer extends BaseRenderer { private final Format expectedFormat; - private boolean isEnded; + public int positionResetCount; + public int formatReadCount; + public int bufferReadCount; + public boolean isEnded; - public FakeVideoRenderer(Format expectedFormat) { - super(C.TRACK_TYPE_VIDEO); + public FakeRenderer(Format expectedFormat) { + super(expectedFormat == null ? C.TRACK_TYPE_UNKNOWN + : MimeTypes.getTrackType(expectedFormat.sampleMimeType)); this.expectedFormat = expectedFormat; } + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + positionResetCount++; + isEnded = false; + } + @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { if (isEnded) { @@ -411,20 +569,23 @@ public final class ExoPlayerTest extends TestCase { // 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; + int result = readSource(formatHolder, buffer); + if (result == C.RESULT_FORMAT_READ) { + formatReadCount++; + assertEquals(expectedFormat, formatHolder.format); + } else if (result == C.RESULT_BUFFER_READ) { + bufferReadCount++; + if (buffer.isEndOfStream()) { + isEnded = true; + } + } } @Override public boolean isReady() { - return isEnded; + return isSourceReady(); } @Override @@ -434,7 +595,21 @@ public final class ExoPlayerTest extends TestCase { @Override public int supportsFormat(Format format) throws ExoPlaybackException { - return MimeTypes.isVideo(format.sampleMimeType) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; + return getTrackType() == MimeTypes.getTrackType(format.sampleMimeType) ? FORMAT_HANDLED + : FORMAT_UNSUPPORTED_TYPE; + } + + } + + private abstract static class FakeMediaClockRenderer extends FakeRenderer implements MediaClock { + + public FakeMediaClockRenderer(Format expectedFormat) { + super(expectedFormat); + } + + @Override + public MediaClock getMediaClock() { + return this; } } 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 c8c1b4ed93..e13afceb40 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/FormatTest.java @@ -59,8 +59,8 @@ public final class FormatTest extends TestCase { 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")); + new TextInformationFrame("id1", "description1", "value1"), + new TextInformationFrame("id2", "description2", "value2")); 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, diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java new file mode 100644 index 0000000000..c7ebb22d9a --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java @@ -0,0 +1,229 @@ +/* + * 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.drm; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.when; + +import android.test.InstrumentationTestCase; +import android.test.MoreAsserts; +import android.util.Pair; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.Period; +import com.google.android.exoplayer2.source.dash.manifest.Representation; +import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import java.util.Arrays; +import java.util.HashMap; +import org.mockito.Mock; + +/** + * Tests {@link OfflineLicenseHelper}. + */ +public class OfflineLicenseHelperTest extends InstrumentationTestCase { + + private OfflineLicenseHelper offlineLicenseHelper; + @Mock private HttpDataSource httpDataSource; + @Mock private MediaDrmCallback mediaDrmCallback; + @Mock private ExoMediaDrm mediaDrm; + + @Override + protected void setUp() throws Exception { + TestUtil.setUpMockito(this); + + when(mediaDrm.openSession()).thenReturn(new byte[] {1, 2, 3}); + + offlineLicenseHelper = new OfflineLicenseHelper<>(mediaDrm, mediaDrmCallback, null); + } + + @Override + protected void tearDown() throws Exception { + offlineLicenseHelper.releaseResources(); + } + + public void testDownloadRenewReleaseKey() throws Exception { + DashManifest manifest = newDashManifestWithAllElements(); + setStubLicenseAndPlaybackDurationValues(1000, 200); + + byte[] keySetId = {2, 5, 8}; + setStubKeySetId(keySetId); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertOfflineLicenseKeySetIdEqual(keySetId, offlineLicenseKeySetId); + + byte[] keySetId2 = {6, 7, 0, 1, 4}; + setStubKeySetId(keySetId2); + + byte[] offlineLicenseKeySetId2 = offlineLicenseHelper.renew(offlineLicenseKeySetId); + + assertOfflineLicenseKeySetIdEqual(keySetId2, offlineLicenseKeySetId2); + + offlineLicenseHelper.release(offlineLicenseKeySetId2); + } + + public void testDownloadFailsIfThereIsNoInitData() throws Exception { + setDefaultStubValues(); + DashManifest manifest = + newDashManifest(newPeriods(newAdaptationSets(newRepresentations(null /*no init data*/)))); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNull(offlineLicenseKeySetId); + } + + public void testDownloadFailsIfThereIsNoRepresentation() throws Exception { + setDefaultStubValues(); + DashManifest manifest = newDashManifest(newPeriods(newAdaptationSets(/*no representation*/))); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNull(offlineLicenseKeySetId); + } + + public void testDownloadFailsIfThereIsNoAdaptationSet() throws Exception { + setDefaultStubValues(); + DashManifest manifest = newDashManifest(newPeriods(/*no adaptation set*/)); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNull(offlineLicenseKeySetId); + } + + public void testDownloadFailsIfThereIsNoPeriod() throws Exception { + setDefaultStubValues(); + DashManifest manifest = newDashManifest(/*no periods*/); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNull(offlineLicenseKeySetId); + } + + public void testDownloadFailsIfNoKeySetIdIsReturned() throws Exception { + setStubLicenseAndPlaybackDurationValues(1000, 200); + DashManifest manifest = newDashManifestWithAllElements(); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNull(offlineLicenseKeySetId); + } + + public void testDownloadDoesNotFailIfDurationNotAvailable() throws Exception { + setDefaultStubKeySetId(); + DashManifest manifest = newDashManifestWithAllElements(); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + assertNotNull(offlineLicenseKeySetId); + } + + public void testGetLicenseDurationRemainingSec() throws Exception { + long licenseDuration = 1000; + int playbackDuration = 200; + setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); + setDefaultStubKeySetId(); + DashManifest manifest = newDashManifestWithAllElements(); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + Pair licenseDurationRemainingSec = offlineLicenseHelper + .getLicenseDurationRemainingSec(offlineLicenseKeySetId); + + assertEquals(licenseDuration, (long) licenseDurationRemainingSec.first); + assertEquals(playbackDuration, (long) licenseDurationRemainingSec.second); + } + + public void testGetLicenseDurationRemainingSecExpiredLicense() throws Exception { + long licenseDuration = 0; + int playbackDuration = 0; + setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); + setDefaultStubKeySetId(); + DashManifest manifest = newDashManifestWithAllElements(); + + byte[] offlineLicenseKeySetId = offlineLicenseHelper.download(httpDataSource, manifest); + + Pair licenseDurationRemainingSec = offlineLicenseHelper + .getLicenseDurationRemainingSec(offlineLicenseKeySetId); + + assertEquals(licenseDuration, (long) licenseDurationRemainingSec.first); + assertEquals(playbackDuration, (long) licenseDurationRemainingSec.second); + } + + private void setDefaultStubValues() + throws android.media.NotProvisionedException, android.media.DeniedByServerException { + setDefaultStubKeySetId(); + setStubLicenseAndPlaybackDurationValues(1000, 200); + } + + private void setDefaultStubKeySetId() + throws android.media.NotProvisionedException, android.media.DeniedByServerException { + setStubKeySetId(new byte[] {2, 5, 8}); + } + + private void setStubKeySetId(byte[] keySetId) + throws android.media.NotProvisionedException, android.media.DeniedByServerException { + when(mediaDrm.provideKeyResponse(any(byte[].class), any(byte[].class))).thenReturn(keySetId); + } + + private static void assertOfflineLicenseKeySetIdEqual( + byte[] expectedKeySetId, byte[] actualKeySetId) throws Exception { + assertNotNull(actualKeySetId); + MoreAsserts.assertEquals(expectedKeySetId, actualKeySetId); + } + + private void setStubLicenseAndPlaybackDurationValues(long licenseDuration, + long playbackDuration) { + HashMap keyStatus = new HashMap<>(); + keyStatus.put(WidevineUtil.PROPERTY_LICENSE_DURATION_REMAINING, + String.valueOf(licenseDuration)); + keyStatus.put(WidevineUtil.PROPERTY_PLAYBACK_DURATION_REMAINING, + String.valueOf(playbackDuration)); + when(mediaDrm.queryKeyStatus(any(byte[].class))).thenReturn(keyStatus); + } + + private static DashManifest newDashManifestWithAllElements() { + return newDashManifest(newPeriods(newAdaptationSets(newRepresentations(newDrmInitData())))); + } + + private static DashManifest newDashManifest(Period... periods) { + return new DashManifest(0, 0, 0, false, 0, 0, 0, null, null, Arrays.asList(periods)); + } + + private static Period newPeriods(AdaptationSet... adaptationSets) { + return new Period("", 0, Arrays.asList(adaptationSets)); + } + + private static AdaptationSet newAdaptationSets(Representation... representations) { + return new AdaptationSet(0, C.TRACK_TYPE_VIDEO, Arrays.asList(representations)); + } + + private static Representation newRepresentations(DrmInitData drmInitData) { + Format format = Format.createVideoSampleFormat("", "", "", 0, 0, 0, 0, 0, null, drmInitData); + return Representation.newInstance("", 0, format, "", new SingleSegmentBase()); + } + + private static DrmInitData newDrmInitData() { + return new DrmInitData(new SchemeData(C.WIDEVINE_UUID, "mimeType", + new byte[]{1, 4, 7, 0, 3, 6})); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java new file mode 100644 index 0000000000..b33dfd1067 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017 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.emsg; + +import android.test.MoreAsserts; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import java.nio.ByteBuffer; +import junit.framework.TestCase; + +/** + * Test for {@link EventMessageDecoder}. + */ +public final class EventMessageDecoderTest extends TestCase { + + public void testDecodeEventMessage() { + byte[] rawEmsgBody = new byte[] { + 117, 114, 110, 58, 116, 101, 115, 116, 0, // scheme_id_uri = "urn:test" + 49, 50, 51, 0, // value = "123" + 0, 0, -69, -128, // timescale = 48000 + 0, 0, 0, 0, // presentation_time_delta (ignored) = 0 + 0, 2, 50, -128, // event_duration = 144000 + 0, 15, 67, -45, // id = 1000403 + 0, 1, 2, 3, 4}; // message_data = {0, 1, 2, 3, 4} + EventMessageDecoder decoder = new EventMessageDecoder(); + MetadataInputBuffer buffer = new MetadataInputBuffer(); + buffer.data = ByteBuffer.allocate(rawEmsgBody.length).put(rawEmsgBody); + Metadata metadata = decoder.decode(buffer); + assertEquals(1, metadata.length()); + EventMessage eventMessage = (EventMessage) metadata.get(0); + assertEquals("urn:test", eventMessage.schemeIdUri); + assertEquals("123", eventMessage.value); + assertEquals(3000, eventMessage.durationMs); + assertEquals(1000403, eventMessage.id); + MoreAsserts.assertEquals(new byte[] {0, 1, 2, 3, 4}, eventMessage.messageData); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageTest.java new file mode 100644 index 0000000000..baafb6b18b --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/emsg/EventMessageTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2017 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.emsg; + +import android.os.Parcel; +import junit.framework.TestCase; + +/** + * Test for {@link EventMessage}. + */ +public final class EventMessageTest extends TestCase { + + public void testEventMessageParcelable() { + EventMessage eventMessage = new EventMessage("urn:test", "123", 3000, 1000403, + new byte[] {0, 1, 2, 3, 4}); + // Write to parcel. + Parcel parcel = Parcel.obtain(); + eventMessage.writeToParcel(parcel, 0); + // Create from parcel. + parcel.setDataPosition(0); + EventMessage fromParcelEventMessage = EventMessage.CREATOR.createFromParcel(parcel); + // Assert equals. + assertEquals(eventMessage, fromParcelEventMessage); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterFrameTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterFrameTest.java new file mode 100644 index 0000000000..182ae6f1c9 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterFrameTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2017 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 junit.framework.TestCase; + +/** + * Test for {@link ChapterFrame}. + */ +public final class ChapterFrameTest extends TestCase { + + public void testParcelable() { + Id3Frame[] subFrames = new Id3Frame[] { + new TextInformationFrame("TIT2", null, "title"), + new UrlLinkFrame("WXXX", "description", "url") + }; + ChapterFrame chapterFrameToParcel = new ChapterFrame("id", 0, 1, 2, 3, subFrames); + + Parcel parcel = Parcel.obtain(); + chapterFrameToParcel.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + + ChapterFrame chapterFrameFromParcel = ChapterFrame.CREATOR.createFromParcel(parcel); + assertEquals(chapterFrameToParcel, chapterFrameFromParcel); + + parcel.recycle(); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java new file mode 100644 index 0000000000..9641de7669 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrameTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2017 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 junit.framework.TestCase; + +/** + * Test for {@link ChapterTocFrame}. + */ +public final class ChapterTocFrameTest extends TestCase { + + public void testParcelable() { + String[] children = new String[] {"child0", "child1"}; + Id3Frame[] subFrames = new Id3Frame[] { + new TextInformationFrame("TIT2", null, "title"), + new UrlLinkFrame("WXXX", "description", "url") + }; + ChapterTocFrame chapterTocFrameToParcel = new ChapterTocFrame("id", false, true, children, + subFrames); + + Parcel parcel = Parcel.obtain(); + chapterTocFrameToParcel.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + + ChapterTocFrame chapterTocFrameFromParcel = ChapterTocFrame.CREATOR.createFromParcel(parcel); + assertEquals(chapterTocFrameToParcel, chapterTocFrameFromParcel); + + parcel.recycle(); + } + +} 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 6bfa6fccfc..e271108ce4 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 @@ -21,9 +21,9 @@ import com.google.android.exoplayer2.metadata.MetadataDecoderException; import junit.framework.TestCase; /** - * Test for {@link Id3Decoder} + * Test for {@link Id3Decoder}. */ -public class Id3DecoderTest extends TestCase { +public final class Id3DecoderTest extends TestCase { public void testDecodeTxxxFrame() throws MetadataDecoderException { byte[] rawId3 = new byte[] {73, 68, 51, 4, 0, 0, 0, 0, 0, 41, 84, 88, 88, 88, 0, 0, 0, 31, 0, 0, @@ -32,9 +32,10 @@ public class Id3DecoderTest extends TestCase { Id3Decoder decoder = new Id3Decoder(); 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); + TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); + assertEquals("TXXX", textInformationFrame.id); + assertEquals("", textInformationFrame.description); + assertEquals("mdialog_VINDICO1527664_start", textInformationFrame.value); } public void testDecodeApicFrame() throws MetadataDecoderException { @@ -60,7 +61,19 @@ public class Id3DecoderTest extends TestCase { assertEquals(1, metadata.length()); TextInformationFrame textInformationFrame = (TextInformationFrame) metadata.get(0); assertEquals("TIT2", textInformationFrame.id); - assertEquals("Hello World", textInformationFrame.description); + assertNull(textInformationFrame.description); + assertEquals("Hello World", textInformationFrame.value); + } + + public void testDecodePrivFrame() throws MetadataDecoderException { + byte[] rawId3 = new byte[] {73, 68, 51, 4, 0, 0, 0, 0, 0, 19, 80, 82, 73, 86, 0, 0, 0, 9, 0, 0, + 116, 101, 115, 116, 0, 1, 2, 3, 4}; + Id3Decoder decoder = new Id3Decoder(); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertEquals(1, metadata.length()); + PrivFrame privFrame = (PrivFrame) metadata.get(0); + assertEquals("test", privFrame.owner); + MoreAsserts.assertEquals(new byte[] {1, 2, 3, 4}, privFrame.privateData); } } 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 008cd0e556..5d10aba1ae 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 @@ -29,13 +29,13 @@ public class RepresentationTest extends TestCase { String uri = "http://www.google.com"; 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); + MimeTypes.VIDEO_H264, 2500000, 1920, 1080, Format.NO_VALUE, null, 0); 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); + MimeTypes.VIDEO_H264, 2500000, 1920, 1080, Format.NO_VALUE, null, 0); representation = Representation.newInstance("test_stream_1", Representation.REVISION_ID_DEFAULT, 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/HlsMasterPlaylistParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java index 488341d4f3..f0adf274ee 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.hls.playlist; import android.net.Uri; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.ParserException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.Charset; @@ -29,70 +30,86 @@ import junit.framework.TestCase; */ public class HlsMasterPlaylistParserTest extends TestCase { - public void testParseMasterPlaylist() { - Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); - String playlistString = "#EXTM3U\n" - + "\n" - + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2,avc1.66.30\",RESOLUTION=304x128\n" - + "http://example.com/low.m3u8\n" - + "\n" - + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2 , avc1.66.30 \"\n" - + "http://example.com/spaces_in_codecs.m3u8\n" - + "\n" - + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,RESOLUTION=384x160\n" - + "http://example.com/mid.m3u8\n" - + "\n" - + "#EXT-X-STREAM-INF:BANDWIDTH=7680000\n" - + "http://example.com/hi.m3u8\n" - + "\n" - + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n" - + "http://example.com/audio-only.m3u8"; - ByteArrayInputStream inputStream = new ByteArrayInputStream( - playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + private static final String PLAYLIST_URI = "https://example.com/test.m3u8"; + + private static final String MASTER_PLAYLIST = " #EXTM3U \n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2,avc1.66.30\",RESOLUTION=304x128\n" + + "http://example.com/low.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2 , avc1.66.30 \"\n" + + "http://example.com/spaces_in_codecs.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,RESOLUTION=384x160\n" + + "http://example.com/mid.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=7680000\n" + + "http://example.com/hi.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n" + + "http://example.com/audio-only.m3u8"; + + private static final String PLAYLIST_WITH_INVALID_HEADER = "#EXTMU3\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2,avc1.66.30\",RESOLUTION=304x128\n" + + "http://example.com/low.m3u8\n"; + + public void testParseMasterPlaylist() throws IOException{ + HlsPlaylist playlist = parsePlaylist(PLAYLIST_URI, MASTER_PLAYLIST); + assertNotNull(playlist); + assertEquals(HlsPlaylist.TYPE_MASTER, playlist.type); + + HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; + + List variants = masterPlaylist.variants; + assertNotNull(variants); + assertEquals(5, variants.size()); + + assertEquals(1280000, variants.get(0).format.bitrate); + assertNotNull(variants.get(0).format.codecs); + assertEquals("mp4a.40.2,avc1.66.30", variants.get(0).format.codecs); + assertEquals(304, variants.get(0).format.width); + assertEquals(128, variants.get(0).format.height); + assertEquals("http://example.com/low.m3u8", variants.get(0).url); + + assertEquals(1280000, variants.get(1).format.bitrate); + assertNotNull(variants.get(1).format.codecs); + assertEquals("mp4a.40.2 , avc1.66.30 ", variants.get(1).format.codecs); + assertEquals("http://example.com/spaces_in_codecs.m3u8", variants.get(1).url); + + assertEquals(2560000, variants.get(2).format.bitrate); + assertEquals(null, variants.get(2).format.codecs); + assertEquals(384, variants.get(2).format.width); + assertEquals(160, variants.get(2).format.height); + assertEquals("http://example.com/mid.m3u8", variants.get(2).url); + + assertEquals(7680000, variants.get(3).format.bitrate); + assertEquals(null, variants.get(3).format.codecs); + assertEquals(Format.NO_VALUE, variants.get(3).format.width); + assertEquals(Format.NO_VALUE, variants.get(3).format.height); + assertEquals("http://example.com/hi.m3u8", variants.get(3).url); + + assertEquals(65000, variants.get(4).format.bitrate); + assertNotNull(variants.get(4).format.codecs); + assertEquals("mp4a.40.5", variants.get(4).format.codecs); + assertEquals(Format.NO_VALUE, variants.get(4).format.width); + assertEquals(Format.NO_VALUE, variants.get(4).format.height); + assertEquals("http://example.com/audio-only.m3u8", variants.get(4).url); + } + + public void testPlaylistWithInvalidHeader() throws IOException { try { - HlsPlaylist playlist = new HlsPlaylistParser().parse(playlistUri, inputStream); - assertNotNull(playlist); - assertEquals(HlsPlaylist.TYPE_MASTER, playlist.type); - - HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; - - List variants = masterPlaylist.variants; - assertNotNull(variants); - assertEquals(5, variants.size()); - - assertEquals(1280000, variants.get(0).format.bitrate); - assertNotNull(variants.get(0).format.codecs); - assertEquals("mp4a.40.2,avc1.66.30", variants.get(0).format.codecs); - assertEquals(304, variants.get(0).format.width); - assertEquals(128, variants.get(0).format.height); - assertEquals("http://example.com/low.m3u8", variants.get(0).url); - - assertEquals(1280000, variants.get(1).format.bitrate); - assertNotNull(variants.get(1).format.codecs); - assertEquals("mp4a.40.2 , avc1.66.30 ", variants.get(1).format.codecs); - assertEquals("http://example.com/spaces_in_codecs.m3u8", variants.get(1).url); - - assertEquals(2560000, variants.get(2).format.bitrate); - assertEquals(null, variants.get(2).format.codecs); - assertEquals(384, variants.get(2).format.width); - assertEquals(160, variants.get(2).format.height); - assertEquals("http://example.com/mid.m3u8", variants.get(2).url); - - assertEquals(7680000, variants.get(3).format.bitrate); - assertEquals(null, variants.get(3).format.codecs); - assertEquals(Format.NO_VALUE, variants.get(3).format.width); - assertEquals(Format.NO_VALUE, variants.get(3).format.height); - assertEquals("http://example.com/hi.m3u8", variants.get(3).url); - - assertEquals(65000, variants.get(4).format.bitrate); - assertNotNull(variants.get(4).format.codecs); - assertEquals("mp4a.40.5", variants.get(4).format.codecs); - assertEquals(Format.NO_VALUE, variants.get(4).format.width); - assertEquals(Format.NO_VALUE, variants.get(4).format.height); - assertEquals("http://example.com/audio-only.m3u8", variants.get(4).url); - } catch (IOException exception) { - fail(exception.getMessage()); + parsePlaylist(PLAYLIST_URI, PLAYLIST_WITH_INVALID_HEADER); + fail("Expected exception not thrown."); + } catch (ParserException e) { + // Expected due to invalid header. } } + private static HlsPlaylist parsePlaylist(String uri, String playlistString) throws IOException { + Uri playlistUri = Uri.parse(uri); + ByteArrayInputStream inputStream = new ByteArrayInputStream( + playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + return new HlsPlaylistParser().parse(playlistUri, inputStream); + } + } 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 67ec907d61..8eacecf9d3 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 @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls.playlist; import android.net.Uri; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -73,59 +74,64 @@ public class HlsMediaPlaylistParserTest extends TestCase { assertEquals(2679, mediaPlaylist.mediaSequence); assertEquals(3, mediaPlaylist.version); - assertEquals(true, mediaPlaylist.hasEndTag); - List segments = mediaPlaylist.segments; + assertTrue(mediaPlaylist.hasEndTag); + List segments = mediaPlaylist.segments; assertNotNull(segments); assertEquals(5, segments.size()); - assertEquals(4, segments.get(0).discontinuitySequenceNumber); - assertEquals(7975000, segments.get(0).durationUs); - assertEquals(false, segments.get(0).isEncrypted); - assertEquals(null, segments.get(0).encryptionKeyUri); - assertEquals(null, segments.get(0).encryptionIV); - assertEquals(51370, segments.get(0).byterangeLength); - assertEquals(0, segments.get(0).byterangeOffset); - assertEquals("https://priv.example.com/fileSequence2679.ts", segments.get(0).url); + Segment segment = segments.get(0); + assertEquals(4, mediaPlaylist.discontinuitySequence + segment.relativeDiscontinuitySequence); + assertEquals(7975000, segment.durationUs); + assertFalse(segment.isEncrypted); + assertEquals(null, segment.encryptionKeyUri); + assertEquals(null, segment.encryptionIV); + assertEquals(51370, segment.byterangeLength); + assertEquals(0, segment.byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2679.ts", segment.url); - assertEquals(4, segments.get(1).discontinuitySequenceNumber); - 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); - assertEquals(51501, segments.get(1).byterangeLength); - assertEquals(2147483648L, segments.get(1).byterangeOffset); - assertEquals("https://priv.example.com/fileSequence2680.ts", segments.get(1).url); + segment = segments.get(1); + assertEquals(0, segment.relativeDiscontinuitySequence); + assertEquals(7975000, segment.durationUs); + assertTrue(segment.isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2680", segment.encryptionKeyUri); + assertEquals("0x1566B", segment.encryptionIV); + assertEquals(51501, segment.byterangeLength); + assertEquals(2147483648L, segment.byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2680.ts", segment.url); - assertEquals(4, segments.get(2).discontinuitySequenceNumber); - assertEquals(7941000, segments.get(2).durationUs); - assertEquals(false, segments.get(2).isEncrypted); - assertEquals(null, segments.get(2).encryptionKeyUri); - assertEquals(null, segments.get(2).encryptionIV); - assertEquals(51501, segments.get(2).byterangeLength); - assertEquals(2147535149L, segments.get(2).byterangeOffset); - assertEquals("https://priv.example.com/fileSequence2681.ts", segments.get(2).url); + segment = segments.get(2); + assertEquals(0, segment.relativeDiscontinuitySequence); + assertEquals(7941000, segment.durationUs); + assertFalse(segment.isEncrypted); + assertEquals(null, segment.encryptionKeyUri); + assertEquals(null, segment.encryptionIV); + assertEquals(51501, segment.byterangeLength); + assertEquals(2147535149L, segment.byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2681.ts", segment.url); - assertEquals(5, segments.get(3).discontinuitySequenceNumber); - 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); + segment = segments.get(3); + assertEquals(1, segment.relativeDiscontinuitySequence); + assertEquals(7975000, segment.durationUs); + assertTrue(segment.isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2682", segment.encryptionKeyUri); // 0xA7A == 2682. - assertNotNull(segments.get(3).encryptionIV); - assertEquals("A7A", segments.get(3).encryptionIV.toUpperCase(Locale.getDefault())); - assertEquals(51740, segments.get(3).byterangeLength); - assertEquals(2147586650L, segments.get(3).byterangeOffset); - assertEquals("https://priv.example.com/fileSequence2682.ts", segments.get(3).url); + assertNotNull(segment.encryptionIV); + assertEquals("A7A", segment.encryptionIV.toUpperCase(Locale.getDefault())); + assertEquals(51740, segment.byterangeLength); + assertEquals(2147586650L, segment.byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2682.ts", segment.url); - assertEquals(5, segments.get(4).discontinuitySequenceNumber); - 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); + segment = segments.get(4); + assertEquals(1, segment.relativeDiscontinuitySequence); + assertEquals(7975000, segment.durationUs); + assertTrue(segment.isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2682", segment.encryptionKeyUri); // 0xA7B == 2683. - assertNotNull(segments.get(4).encryptionIV); - assertEquals("A7B", segments.get(4).encryptionIV.toUpperCase(Locale.getDefault())); - assertEquals(C.LENGTH_UNSET, segments.get(4).byterangeLength); - assertEquals(0, segments.get(4).byterangeOffset); - assertEquals("https://priv.example.com/fileSequence2683.ts", segments.get(4).url); + assertNotNull(segment.encryptionIV); + assertEquals("A7B", segment.encryptionIV.toUpperCase(Locale.getDefault())); + assertEquals(C.LENGTH_UNSET, segment.byterangeLength); + assertEquals(0, segment.byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2683.ts", segment.url); } catch (IOException exception) { fail(exception.getMessage()); } 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 18e39be93c..c9eaa33204 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 @@ -27,7 +27,9 @@ import java.io.File; import java.io.IOException; import java.util.Arrays; -/** Unit tests for {@link CacheDataSource}. */ +/** + * Unit tests for {@link CacheDataSource}. + */ public class CacheDataSourceTest extends InstrumentationTestCase { private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java new file mode 100644 index 0000000000..70a7d797c1 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2017 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.content.Context; +import android.net.Uri; +import android.test.AndroidTestCase; +import android.test.MoreAsserts; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSink; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Random; + +/** + * Additional tests for {@link CacheDataSource}. + */ +public class CacheDataSourceTest2 extends AndroidTestCase { + + private static final String EXO_CACHE_DIR = "exo"; + private static final int EXO_CACHE_MAX_FILESIZE = 128; + + private static final Uri URI = Uri.parse("http://test.com/content"); + private static final String KEY = "key"; + private static final byte[] DATA = TestUtil.buildTestData(8 * EXO_CACHE_MAX_FILESIZE + 1); + + // A DataSpec that covers the full file. + private static final DataSpec FULL = new DataSpec(URI, 0, DATA.length, KEY); + + private static final int OFFSET_ON_BOUNDARY = EXO_CACHE_MAX_FILESIZE; + // A DataSpec that starts at 0 and extends to a cache file boundary. + private static final DataSpec END_ON_BOUNDARY = new DataSpec(URI, 0, OFFSET_ON_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_ON_BOUNDARY = new DataSpec(URI, OFFSET_ON_BOUNDARY, + DATA.length - OFFSET_ON_BOUNDARY, KEY); + + private static final int OFFSET_OFF_BOUNDARY = EXO_CACHE_MAX_FILESIZE * 2 + 1; + // A DataSpec that starts at 0 and extends to just past a cache file boundary. + private static final DataSpec END_OFF_BOUNDARY = new DataSpec(URI, 0, OFFSET_OFF_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_OFF_BOUNDARY = new DataSpec(URI, OFFSET_OFF_BOUNDARY, + DATA.length - OFFSET_OFF_BOUNDARY, KEY); + + public void testWithoutEncryption() throws IOException { + testReads(false); + } + + public void testWithEncryption() throws IOException { + testReads(true); + } + + private void testReads(boolean useEncryption) throws IOException { + FakeDataSource upstreamSource = buildFakeUpstreamSource(); + CacheDataSource source = buildCacheDataSource(getContext(), upstreamSource, useEncryption); + // First read, should arrive from upstream. + testRead(END_ON_BOUNDARY, source); + assertSingleOpen(upstreamSource, 0, OFFSET_ON_BOUNDARY); + // Second read, should arrive from upstream. + testRead(START_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_OFF_BOUNDARY, DATA.length); + // Second read, should arrive part from cache and part from upstream. + testRead(END_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_ON_BOUNDARY, OFFSET_OFF_BOUNDARY); + // Third read, should arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + // Various reads, should all arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + testRead(START_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(START_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + } + + private void testRead(DataSpec dataSpec, CacheDataSource source) throws IOException { + byte[] scratch = new byte[4096]; + Random random = new Random(0); + source.open(dataSpec); + int position = (int) dataSpec.absoluteStreamPosition; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + int maxBytesToRead = random.nextInt(scratch.length) + 1; + bytesRead = source.read(scratch, 0, maxBytesToRead); + if (bytesRead != C.RESULT_END_OF_INPUT) { + MoreAsserts.assertEquals(Arrays.copyOfRange(DATA, position, position + bytesRead), + Arrays.copyOf(scratch, bytesRead)); + position += bytesRead; + } + } + source.close(); + } + + /** + * Asserts that a single {@link DataSource#open(DataSpec)} call has been made to the upstream + * source, with the specified start (inclusive) and end (exclusive) positions. + */ + private void assertSingleOpen(FakeDataSource upstreamSource, int start, int end) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(1, openedDataSpecs.length); + assertEquals(start, openedDataSpecs[0].position); + assertEquals(start, openedDataSpecs[0].absoluteStreamPosition); + assertEquals(end - start, openedDataSpecs[0].length); + } + + /** + * Asserts that the upstream source was not opened. + */ + private void assertNoOpen(FakeDataSource upstreamSource) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(0, openedDataSpecs.length); + } + + private static FakeDataSource buildFakeUpstreamSource() { + return new FakeDataSource.Builder().appendReadData(DATA).build(); + } + + private static CacheDataSource buildCacheDataSource(Context context, DataSource upstreamSource, + boolean useAesEncryption) throws CacheException { + File cacheDir = context.getExternalCacheDir(); + Cache cache = new SimpleCache(new File(cacheDir, EXO_CACHE_DIR), new NoOpCacheEvictor()); + emptyCache(cache); + + // Source and cipher + final String secretKey = "testKey:12345678"; + DataSource file = new FileDataSource(); + DataSource cacheReadDataSource = useAesEncryption + ? new AesCipherDataSource(Util.getUtf8Bytes(secretKey), file) : file; + + // Sink and cipher + CacheDataSink cacheSink = new CacheDataSink(cache, EXO_CACHE_MAX_FILESIZE); + byte[] scratch = new byte[3897]; + DataSink cacheWriteDataSink = useAesEncryption + ? new AesCipherDataSink(Util.getUtf8Bytes(secretKey), cacheSink, scratch) : cacheSink; + + return new CacheDataSource(cache, + upstreamSource, + cacheReadDataSource, + cacheWriteDataSink, + CacheDataSource.FLAG_BLOCK_ON_CACHE, + null); // eventListener + } + + private static void emptyCache(Cache cache) throws CacheException { + for (String key : cache.getKeys()) { + for (CacheSpan span : cache.getCachedSpans(key)) { + cache.removeSpan(span); + } + } + // Sanity check that the cache really is empty now. + assertTrue(cache.getKeys().isEmpty()); + } + +} 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 index dd4de7cce2..4fbcc92e3d 100644 --- 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 @@ -163,7 +163,7 @@ public class CachedContentIndexTest extends InstrumentationTestCase { 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 + byte[] key2 = "Foo12345Foo12345".getBytes(C.UTF8_NAME); // 128 bit key assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), new CachedContentIndex(cacheDir, key)); @@ -181,7 +181,7 @@ public class CachedContentIndexTest extends InstrumentationTestCase { // Assert file content is different FileInputStream fis1 = new FileInputStream(file1); FileInputStream fis2 = new FileInputStream(file2); - for (int b; (b = fis1.read()) == fis2.read();) { + for (int b; (b = fis1.read()) == fis2.read(); ) { assertTrue(b != -1); } @@ -205,6 +205,12 @@ public class CachedContentIndexTest extends InstrumentationTestCase { // Non encrypted index file can be read even when encryption key provided. assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir), new CachedContentIndex(cacheDir, key)); + + // Test multiple store() calls + CachedContentIndex index = new CachedContentIndex(cacheDir, key); + index.addNew(new CachedContent(15, "key3", 110)); + index.store(); + assertStoredAndLoadedEqual(index, new CachedContentIndex(cacheDir, key)); } private void assertStoredAndLoadedEqual(CachedContentIndex index, CachedContentIndex index2) diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java new file mode 100644 index 0000000000..799027f4b5 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java @@ -0,0 +1,126 @@ +/* + * 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.extractor.ChunkIndex; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.IOException; +import org.mockito.Mock; + +/** + * Tests for {@link CachedRegionTracker}. + */ +public final class CachedRegionTrackerTest extends InstrumentationTestCase { + + private static final String CACHE_KEY = "abc"; + private static final long MS_IN_US = 1000; + + // 5 chunks, each 20 bytes long and 100 ms long. + private static final ChunkIndex CHUNK_INDEX = new ChunkIndex( + new int[] {20, 20, 20, 20, 20}, + new long[] {100, 120, 140, 160, 180}, + new long[] {100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US}, + new long[] {0, 100 * MS_IN_US, 200 * MS_IN_US, 300 * MS_IN_US, 400 * MS_IN_US}); + + @Mock private Cache cache; + private CachedRegionTracker tracker; + + private CachedContentIndex index; + private File cacheDir; + + @Override + protected void setUp() throws Exception { + TestUtil.setUpMockito(this); + + tracker = new CachedRegionTracker(cache, CACHE_KEY, CHUNK_INDEX); + + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); + index = new CachedContentIndex(cacheDir); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(cacheDir); + } + + public void testGetRegion_noSpansInCache() { + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(100)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(150)); + } + + public void testGetRegion_fullyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_partiallyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_multipleSpanAddsJoinedCorrectly() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 20)); + tracker.onSpanAdded( + cache, + newCacheSpan(120, 20)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_fullyCachedThenPartiallyRemoved() throws Exception { + // Start with the full stream in cache. + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + // Remove the middle bit. + tracker.onSpanRemoved( + cache, + newCacheSpan(140, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(181)); + } + + public void testGetRegion_subchunkEstimation() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 10)); + + assertEquals(50, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(111)); + } + + private CacheSpan newCacheSpan(int position, int length) throws IOException { + return SimpleCacheSpanTest.createCacheSpan(index, cacheDir, CACHE_KEY, position, length, 0); + } + +} 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 5f539c6213..001c6adc87 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,12 +16,16 @@ package com.google.android.exoplayer2.upstream.cache; import android.test.InstrumentationTestCase; +import android.test.MoreAsserts; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Util; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.NavigableSet; +import java.util.Random; import java.util.Set; /** @@ -46,9 +50,9 @@ public class SimpleCacheTest extends InstrumentationTestCase { public void testCommittingOneFile() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); - assertFalse(cacheSpan.isCached); - assertTrue(cacheSpan.isOpenEnded()); + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + assertFalse(cacheSpan1.isCached); + assertTrue(cacheSpan1.isOpenEnded()); assertNull(simpleCache.startReadWriteNonBlocking(KEY_1, 0)); @@ -58,20 +62,33 @@ public class SimpleCacheTest extends InstrumentationTestCase { assertEquals(0, simpleCache.getCacheSpace()); assertEquals(0, cacheDir.listFiles().length); - addCache(simpleCache, 0, 15); + addCache(simpleCache, KEY_1, 0, 15); Set cachedKeys = simpleCache.getKeys(); assertEquals(1, cachedKeys.size()); assertTrue(cachedKeys.contains(KEY_1)); cachedSpans = simpleCache.getCachedSpans(KEY_1); assertEquals(1, cachedSpans.size()); - assertTrue(cachedSpans.contains(cacheSpan)); + assertTrue(cachedSpans.contains(cacheSpan1)); assertEquals(15, simpleCache.getCacheSpace()); - cacheSpan = simpleCache.startReadWrite(KEY_1, 0); - assertTrue(cacheSpan.isCached); - assertFalse(cacheSpan.isOpenEnded()); - assertEquals(15, cacheSpan.length); + simpleCache.releaseHoleSpan(cacheSpan1); + + CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); + assertTrue(cacheSpan2.isCached); + assertFalse(cacheSpan2.isOpenEnded()); + assertEquals(15, cacheSpan2.length); + assertCachedDataReadCorrect(cacheSpan2); + } + + public void testReadCacheWithoutReleasingWriteCacheSpan() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); + assertCachedDataReadCorrect(cacheSpan2); + simpleCache.releaseHoleSpan(cacheSpan1); } public void testSetGetLength() throws Exception { @@ -83,12 +100,12 @@ public class SimpleCacheTest extends InstrumentationTestCase { simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, 0, 15); + addCache(simpleCache, KEY_1, 0, 15); simpleCache.setContentLength(KEY_1, 150); assertEquals(150, simpleCache.getContentLength(KEY_1)); - addCache(simpleCache, 140, 10); + addCache(simpleCache, KEY_1, 140, 10); // Check if values are kept after cache is reloaded. SimpleCache simpleCache2 = getSimpleCache(); @@ -107,16 +124,109 @@ public class SimpleCacheTest extends InstrumentationTestCase { assertEquals(150, simpleCache2.getContentLength(KEY_1)); } + public void testReloadCache() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + // write data + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(cacheSpan1); + + // Reload cache + simpleCache = getSimpleCache(); + + // read data back + CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); + assertCachedDataReadCorrect(cacheSpan2); + } + + public void testEncryptedIndex() throws Exception { + byte[] key = "Bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + SimpleCache simpleCache = getEncryptedSimpleCache(key); + + // write data + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(cacheSpan1); + + // Reload cache + simpleCache = getEncryptedSimpleCache(key); + + // read data back + CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); + assertCachedDataReadCorrect(cacheSpan2); + } + + public void testEncryptedIndexWrongKey() throws Exception { + byte[] key = "Bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + SimpleCache simpleCache = getEncryptedSimpleCache(key); + + // write data + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(cacheSpan1); + + // Reload cache + byte[] key2 = "Foo12345Foo12345".getBytes(C.UTF8_NAME); // 128 bit key + simpleCache = getEncryptedSimpleCache(key2); + + // Cache should be cleared + assertEquals(0, simpleCache.getKeys().size()); + assertEquals(0, cacheDir.listFiles().length); + } + + public void testEncryptedIndexLostKey() throws Exception { + byte[] key = "Bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + SimpleCache simpleCache = getEncryptedSimpleCache(key); + + // write data + CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(cacheSpan1); + + // Reload cache + simpleCache = getSimpleCache(); + + // Cache should be cleared + assertEquals(0, simpleCache.getKeys().size()); + assertEquals(0, cacheDir.listFiles().length); + } + private SimpleCache getSimpleCache() { return new SimpleCache(cacheDir, new NoOpCacheEvictor()); } - private void addCache(SimpleCache simpleCache, int position, int length) throws IOException { - File file = simpleCache.startFile(KEY_1, position, length); + private SimpleCache getEncryptedSimpleCache(byte[] secretKey) { + return new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey); + } + + private static void addCache(SimpleCache simpleCache, String key, int position, int length) + throws IOException { + File file = simpleCache.startFile(key, position, length); FileOutputStream fos = new FileOutputStream(file); - fos.write(new byte[length]); - fos.close(); + try { + fos.write(generateData(key, position, length)); + } finally { + fos.close(); + } simpleCache.commitFile(file); } + private static void assertCachedDataReadCorrect(CacheSpan cacheSpan) throws IOException { + assertTrue(cacheSpan.isCached); + byte[] expected = generateData(cacheSpan.key, (int) cacheSpan.position, (int) cacheSpan.length); + FileInputStream inputStream = new FileInputStream(cacheSpan.file); + try { + MoreAsserts.assertEquals(expected, Util.toByteArray(inputStream)); + } finally { + inputStream.close(); + } + } + + private static byte[] generateData(String key, int position, int length) { + byte[] bytes = new byte[length]; + new Random((long) (key.hashCode() ^ position)).nextBytes(bytes); + return bytes; + } + } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java new file mode 100644 index 0000000000..b4e7e6e7f6 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java @@ -0,0 +1,186 @@ +/* + * 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.crypto; + +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.Random; +import javax.crypto.Cipher; +import junit.framework.TestCase; + +/** + * Unit tests for {@link AesFlushingCipher}. + */ +public class AesFlushingCipherTest extends TestCase { + + private static final int DATA_LENGTH = 65536; + private static final byte[] KEY = Util.getUtf8Bytes("testKey:12345678"); + private static final long NONCE = 0; + private static final long START_OFFSET = 11; + private static final long RANDOM_SEED = 0x12345678; + + private AesFlushingCipher encryptCipher; + private AesFlushingCipher decryptCipher; + + @Override + protected void setUp() { + encryptCipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, KEY, NONCE, START_OFFSET); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, START_OFFSET); + } + + @Override + protected void tearDown() { + encryptCipher = null; + decryptCipher = null; + } + + private long getMaxUnchangedBytesAllowedPostEncryption(long length) { + // Assuming that not more than 10% of the resultant bytes should be identical. + // The value of 10% is arbitrary, ciphers standards do not name a value. + return length / 10; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2, int startOffset) { + int count = 0; + for (int i = startOffset; i < data1.length; i++) { + if (data1[i] != data2[i]) { + count++; + } + } + return count; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2) { + return getDifferingByteCount(data1, data2, 0); + } + + // Test a single encrypt and decrypt call + public void testSingle() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + + encryptCipher.updateInPlace(data, 0, data.length); + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + decryptCipher.updateInPlace(data, 0, data.length); + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, each aligned on a 16 byte block size + public void testAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + int offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, not aligned on block boundary + public void testUnAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test decryption starting from the middle of an encrypted block + public void testMidJoin() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + // Verify + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + // Setup decryption from random location + offset = random.nextInt(4096); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, offset + START_OFFSET); + int remainingLength = data.length - offset; + int originalOffset = offset; + + // Decrypt + while (remainingLength > 0) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, remainingLength); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + remainingLength -= bytes; + } + + // Verify + int differingByteCount = getDifferingByteCount(reference, data, originalOffset); + assertEquals(0, differingByteCount); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java index a747930152..49719b95f7 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java @@ -371,6 +371,73 @@ public class ParsableByteArrayTest extends TestCase { assertNull(parser.readLine()); } + public void testReadNullTerminatedStringWithLengths() { + byte[] bytes = new byte[] { + 'f', 'o', 'o', 0, 'b', 'a', 'r', 0 + }; + // Test with lengths that match NUL byte positions. + ParsableByteArray parser = new ParsableByteArray(bytes); + assertEquals("foo", parser.readNullTerminatedString(4)); + assertEquals(4, parser.getPosition()); + assertEquals("bar", parser.readNullTerminatedString(4)); + assertEquals(8, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + // Test with lengths that do not match NUL byte positions. + parser = new ParsableByteArray(bytes); + assertEquals("fo", parser.readNullTerminatedString(2)); + assertEquals(2, parser.getPosition()); + assertEquals("o", parser.readNullTerminatedString(2)); + assertEquals(4, parser.getPosition()); + assertEquals("bar", parser.readNullTerminatedString(3)); + assertEquals(7, parser.getPosition()); + assertEquals("", parser.readNullTerminatedString(1)); + assertEquals(8, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + // Test with limit at NUL + parser = new ParsableByteArray(bytes, 4); + assertEquals("foo", parser.readNullTerminatedString(4)); + assertEquals(4, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + // Test with limit before NUL + parser = new ParsableByteArray(bytes, 3); + assertEquals("foo", parser.readNullTerminatedString(3)); + assertEquals(3, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + } + + public void testReadNullTerminatedString() { + byte[] bytes = new byte[] { + 'f', 'o', 'o', 0, 'b', 'a', 'r', 0 + }; + // Test normal case. + ParsableByteArray parser = new ParsableByteArray(bytes); + assertEquals("foo", parser.readNullTerminatedString()); + assertEquals(4, parser.getPosition()); + assertEquals("bar", parser.readNullTerminatedString()); + assertEquals(8, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + // Test with limit at NUL. + parser = new ParsableByteArray(bytes, 4); + assertEquals("foo", parser.readNullTerminatedString()); + assertEquals(4, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + // Test with limit before NUL. + parser = new ParsableByteArray(bytes, 3); + assertEquals("foo", parser.readNullTerminatedString()); + assertEquals(3, parser.getPosition()); + assertNull(parser.readNullTerminatedString()); + } + + public void testReadNullTerminatedStringWithoutEndingNull() { + byte[] bytes = new byte[] { + 'f', 'o', 'o', 0, 'b', 'a', 'r' + }; + ParsableByteArray parser = new ParsableByteArray(bytes); + assertEquals("foo", parser.readNullTerminatedString()); + assertEquals("bar", parser.readNullTerminatedString()); + assertNull(parser.readNullTerminatedString()); + } + public void testReadSingleLineWithoutEndingTrail() { byte[] bytes = new byte[] { 'f', 'o', 'o' 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 514bbca8f4..9973a50cff 100644 --- a/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -28,6 +28,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { private final int trackType; + private RendererConfiguration configuration; private int index; private int state; private SampleStream stream; @@ -70,9 +71,11 @@ 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(RendererConfiguration configuration, Format[] formats, + SampleStream stream, long positionUs, boolean joining, long offsetUs) + throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); + this.configuration = configuration; state = STATE_ENABLED; onEnabled(joining); replaceStream(formats, stream, offsetUs); @@ -237,10 +240,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { // Methods to be called by subclasses. + /** + * Returns the configuration set when the renderer was most recently enabled. + */ + protected final RendererConfiguration getConfiguration() { + return configuration; + } + /** * Returns the index of the renderer within the player. - * - * @return The index of the renderer within the player. */ protected final int getIndex() { return index; @@ -251,11 +259,11 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * {@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) * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the * end of the stream. If the end of the stream has been reached, the - * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. + * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. May be null if the + * caller requires that the format of the stream be read even if it's not changing. * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or * {@link C#RESULT_BUFFER_READ}. */ 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 5cef177517..0b1c33bfc9 100644 --- a/library/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/src/main/java/com/google/android/exoplayer2/C.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import android.annotation.TargetApi; +import android.content.Context; import android.media.AudioFormat; import android.media.AudioManager; import android.media.MediaCodec; @@ -550,4 +552,13 @@ public final class C { return timeMs == TIME_UNSET ? TIME_UNSET : (timeMs * 1000); } + /** + * Returns a newly generated {@link android.media.AudioTrack} session identifier. + */ + @TargetApi(21) + public static int generateAudioSessionIdV21(Context context) { + return ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)) + .generateAudioSessionId(); + } + } 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 e6a39d8a27..fe7015a942 100644 --- a/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java +++ b/library/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java @@ -19,6 +19,7 @@ import com.google.android.exoplayer2.source.TrackGroupArray; 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.PriorityTaskManager; import com.google.android.exoplayer2.util.Util; /** @@ -50,6 +51,11 @@ public final class DefaultLoadControl implements LoadControl { */ public static final int DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5000; + /** + * Priority for media loading. + */ + public static final int LOADING_PRIORITY = 0; + private static final int ABOVE_HIGH_WATERMARK = 0; private static final int BETWEEN_WATERMARKS = 1; private static final int BELOW_LOW_WATERMARK = 2; @@ -60,6 +66,7 @@ public final class DefaultLoadControl implements LoadControl { private final long maxBufferUs; private final long bufferForPlaybackUs; private final long bufferForPlaybackAfterRebufferUs; + private final PriorityTaskManager priorityTaskManager; private int targetBufferSize; private boolean isBuffering; @@ -97,11 +104,36 @@ public final class DefaultLoadControl implements LoadControl { */ public DefaultLoadControl(DefaultAllocator allocator, int minBufferMs, int maxBufferMs, long bufferForPlaybackMs, long bufferForPlaybackAfterRebufferMs) { + this(allocator, minBufferMs, maxBufferMs, bufferForPlaybackMs, bufferForPlaybackAfterRebufferMs, + null); + } + + /** + * Constructs a new instance. + * + * @param allocator The {@link DefaultAllocator} used by the loader. + * @param minBufferMs The minimum duration of media that the player will attempt to ensure is + * buffered at all times, in milliseconds. + * @param maxBufferMs The maximum duration of media that the player will attempt buffer, in + * milliseconds. + * @param bufferForPlaybackMs The duration of media that must be buffered for playback to start or + * resume following a user action such as a seek, in milliseconds. + * @param bufferForPlaybackAfterRebufferMs The default duration of media that must be buffered for + * playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be caused by + * buffer depletion rather than a user action. + * @param priorityTaskManager If not null, registers itself as a task with priority + * {@link #LOADING_PRIORITY} during loading periods, and unregisters itself during draining + * periods. + */ + public DefaultLoadControl(DefaultAllocator allocator, int minBufferMs, int maxBufferMs, + long bufferForPlaybackMs, long bufferForPlaybackAfterRebufferMs, + PriorityTaskManager priorityTaskManager) { this.allocator = allocator; minBufferUs = minBufferMs * 1000L; maxBufferUs = maxBufferMs * 1000L; bufferForPlaybackUs = bufferForPlaybackMs * 1000L; bufferForPlaybackAfterRebufferUs = bufferForPlaybackAfterRebufferMs * 1000L; + this.priorityTaskManager = priorityTaskManager; } @Override @@ -146,8 +178,16 @@ public final class DefaultLoadControl implements LoadControl { public boolean shouldContinueLoading(long bufferedDurationUs) { int bufferTimeState = getBufferTimeState(bufferedDurationUs); boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferSize; + boolean wasBuffering = isBuffering; isBuffering = bufferTimeState == BELOW_LOW_WATERMARK || (bufferTimeState == BETWEEN_WATERMARKS && isBuffering && !targetBufferSizeReached); + if (priorityTaskManager != null && isBuffering != wasBuffering) { + if (isBuffering) { + priorityTaskManager.add(LOADING_PRIORITY); + } else { + priorityTaskManager.remove(LOADING_PRIORITY); + } + } return isBuffering; } @@ -158,6 +198,9 @@ public final class DefaultLoadControl implements LoadControl { private void reset(boolean resetAllocator) { targetBufferSize = 0; + if (priorityTaskManager != null && isBuffering) { + priorityTaskManager.remove(LOADING_PRIORITY); + } isBuffering = false; if (resetAllocator) { allocator.reset(); 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 6c64d2c0f3..083569416c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -447,4 +447,20 @@ public interface ExoPlayer { */ int getBufferedPercentage(); + /** + * Returns whether the current window is dynamic, or {@code false} if the {@link Timeline} is + * empty. + * + * @see Timeline.Window#isDynamic + */ + boolean isCurrentWindowDynamic(); + + /** + * Returns whether the current window is seekable, or {@code false} if the {@link Timeline} is + * empty. + * + * @see Timeline.Window#isSeekable + */ + boolean isCurrentWindowSeekable(); + } 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 ab4e59e08f..d44d138091 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -22,12 +22,12 @@ import android.os.Message; import android.util.Log; 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.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.util.concurrent.CopyOnWriteArraySet; @@ -271,6 +271,22 @@ import java.util.concurrent.CopyOnWriteArraySet; : (int) (duration == 0 ? 100 : (bufferedPosition * 100) / duration); } + @Override + public boolean isCurrentWindowDynamic() { + if (timeline.isEmpty()) { + return false; + } + return timeline.getWindow(getCurrentWindowIndex(), window).isDynamic; + } + + @Override + public boolean isCurrentWindowSeekable() { + if (timeline.isEmpty()) { + return false; + } + return timeline.getWindow(getCurrentWindowIndex(), window).isSeekable; + } + @Override public int getRendererCount() { return renderers.length; @@ -319,11 +335,11 @@ import java.util.concurrent.CopyOnWriteArraySet; break; } case ExoPlayerImplInternal.MSG_TRACKS_CHANGED: { - TrackInfo trackInfo = (TrackInfo) msg.obj; + TrackSelectorResult trackSelectorResult = (TrackSelectorResult) msg.obj; tracksSelected = true; - trackGroups = trackInfo.groups; - trackSelections = trackInfo.selections; - trackSelector.onSelectionActivated(trackInfo.info); + trackGroups = trackSelectorResult.groups; + trackSelections = trackSelectorResult.selections; + trackSelector.onSelectionActivated(trackSelectorResult.info); for (EventListener listener : listeners) { listener.onTracksChanged(trackGroups, trackSelections); } 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 afae56f1aa..266a1e0da2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -26,16 +26,15 @@ 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.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.PriorityHandlerThread; import com.google.android.exoplayer2.util.StandaloneMediaClock; import com.google.android.exoplayer2.util.TraceUtil; -import com.google.android.exoplayer2.util.Util; import java.io.IOException; /** @@ -72,20 +71,6 @@ import java.io.IOException; } - 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; @@ -624,6 +609,7 @@ import java.io.IOException; enabledRenderers = new Renderer[0]; rendererMediaClock = null; rendererMediaClockSource = null; + playingPeriodHolder = null; } // Update the holders. @@ -799,7 +785,8 @@ import java.io.IOException; } } } - eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.getTrackInfo()).sendToTarget(); + eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.trackSelectorResult) + .sendToTarget(); enableRenderers(rendererWasEnabledFlags, enabledRendererCount); } else { // Release and re-prepare/buffer periods after the one whose selection changed. @@ -1138,33 +1125,38 @@ import java.io.IOException; } if (readingPeriodHolder.isLast) { - for (Renderer renderer : enabledRenderers) { + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + SampleStream sampleStream = readingPeriodHolder.sampleStreams[i]; // 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()) { + if (sampleStream != null && renderer.getStream() == sampleStream + && renderer.hasReadStreamToEnd()) { renderer.setCurrentStreamFinal(); } } return; } - for (Renderer renderer : enabledRenderers) { - if (!renderer.hasReadStreamToEnd()) { + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + SampleStream sampleStream = readingPeriodHolder.sampleStreams[i]; + if (renderer.getStream() != sampleStream + || (sampleStream != null && !renderer.hasReadStreamToEnd())) { return; } } if (readingPeriodHolder.next != null && readingPeriodHolder.next.prepared) { - TrackSelectionArray oldTrackSelections = readingPeriodHolder.trackSelections; + TrackSelectorResult oldTrackSelectorResult = readingPeriodHolder.trackSelectorResult; readingPeriodHolder = readingPeriodHolder.next; - TrackSelectionArray newTrackSelections = readingPeriodHolder.trackSelections; + TrackSelectorResult newTrackSelectorResult = readingPeriodHolder.trackSelectorResult; 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); + TrackSelection oldSelection = oldTrackSelectorResult.selections.get(i); if (oldSelection == null) { // The renderer has no current stream and will be enabled when we play the next period. } else if (initialDiscontinuity) { @@ -1172,9 +1164,12 @@ import java.io.IOException; // 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. + TrackSelection newSelection = newTrackSelectorResult.selections.get(i); + RendererConfiguration oldConfig = oldTrackSelectorResult.rendererConfigurations[i]; + RendererConfiguration newConfig = newTrackSelectorResult.rendererConfigurations[i]; + if (newSelection != null && newConfig.equals(oldConfig)) { + // 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); @@ -1182,8 +1177,9 @@ import java.io.IOException; renderer.replaceStream(formats, readingPeriodHolder.sampleStreams[i], 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. + // The renderer will be disabled when transitioning to playing the next period, either + // because there's no new selection or because a configuration change is required. Mark + // the SampleStream as final to play out any remaining data. renderer.setCurrentStreamFinal(); } } @@ -1319,20 +1315,21 @@ import java.io.IOException; return; } - playingPeriodHolder = periodHolder; int enabledRendererCount = 0; boolean[] rendererWasEnabledFlags = new boolean[renderers.length]; for (int i = 0; i < renderers.length; i++) { Renderer renderer = renderers[i]; rendererWasEnabledFlags[i] = renderer.getState() != Renderer.STATE_DISABLED; - TrackSelection newSelection = periodHolder.trackSelections.get(i); + TrackSelection newSelection = periodHolder.trackSelectorResult.selections.get(i); if (newSelection != null) { enabledRendererCount++; } - if (rendererWasEnabledFlags[i] && (newSelection == null || renderer.isCurrentStreamFinal())) { + if (rendererWasEnabledFlags[i] && (newSelection == null + || (renderer.isCurrentStreamFinal() + && renderer.getStream() == playingPeriodHolder.sampleStreams[i]))) { // 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. + // needed to play the next period, or because we need to re-enable it as its current stream + // is final and it's not reading ahead. if (renderer == rendererMediaClockSource) { // Sync standaloneMediaClock so that it can take over timing responsibilities. standaloneMediaClock.setPositionUs(rendererMediaClock.getPositionUs()); @@ -1344,7 +1341,8 @@ import java.io.IOException; } } - eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.getTrackInfo()).sendToTarget(); + playingPeriodHolder = periodHolder; + eventHandler.obtainMessage(MSG_TRACKS_CHANGED, periodHolder.trackSelectorResult).sendToTarget(); enableRenderers(rendererWasEnabledFlags, enabledRendererCount); } @@ -1354,10 +1352,12 @@ import java.io.IOException; enabledRendererCount = 0; for (int i = 0; i < renderers.length; i++) { Renderer renderer = renderers[i]; - TrackSelection newSelection = playingPeriodHolder.trackSelections.get(i); + TrackSelection newSelection = playingPeriodHolder.trackSelectorResult.selections.get(i); if (newSelection != null) { enabledRenderers[enabledRendererCount++] = renderer; if (renderer.getState() == Renderer.STATE_DISABLED) { + RendererConfiguration rendererConfiguration = + playingPeriodHolder.trackSelectorResult.rendererConfigurations[i]; // The renderer needs enabling with its new track selection. boolean playing = playWhenReady && state == ExoPlayer.STATE_READY; // Consider as joining only if the renderer was previously disabled. @@ -1368,8 +1368,8 @@ import java.io.IOException; formats[j] = newSelection.getFormat(j); } // Enable the renderer. - renderer.enable(formats, playingPeriodHolder.sampleStreams[i], rendererPositionUs, - joining, playingPeriodHolder.getRendererOffset()); + renderer.enable(rendererConfiguration, formats, playingPeriodHolder.sampleStreams[i], + rendererPositionUs, joining, playingPeriodHolder.getRendererOffset()); MediaClock mediaClock = renderer.getMediaClock(); if (mediaClock != null) { if (rendererMediaClock != null) { @@ -1406,6 +1406,7 @@ import java.io.IOException; public boolean hasEnabledTracks; public MediaPeriodHolder next; public boolean needsContinueLoading; + public TrackSelectorResult trackSelectorResult; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -1413,10 +1414,7 @@ import java.io.IOException; private final LoadControl loadControl; private final MediaSource mediaSource; - private Object trackSelectionsInfo; - private TrackGroupArray trackGroups; - private TrackSelectionArray trackSelections; - private TrackSelectionArray periodTrackSelections; + private TrackSelectorResult periodTrackSelectorResult; public MediaPeriodHolder(Renderer[] renderers, RendererCapabilities[] rendererCapabilities, long rendererPositionOffsetUs, TrackSelector trackSelector, LoadControl loadControl, @@ -1462,20 +1460,17 @@ import java.io.IOException; public void handlePrepared() throws ExoPlaybackException { prepared = true; - trackGroups = mediaPeriod.getTrackGroups(); selectTracks(); startPositionUs = updatePeriodTrackSelection(startPositionUs, false); } public boolean selectTracks() throws ExoPlaybackException { - Pair selectorResult = trackSelector.selectTracks( - rendererCapabilities, trackGroups); - TrackSelectionArray newTrackSelections = selectorResult.first; - if (newTrackSelections.equals(periodTrackSelections)) { + TrackSelectorResult selectorResult = trackSelector.selectTracks(rendererCapabilities, + mediaPeriod.getTrackGroups()); + if (selectorResult.isEquivalent(periodTrackSelectorResult)) { return false; } - trackSelections = newTrackSelections; - trackSelectionsInfo = selectorResult.second; + trackSelectorResult = selectorResult; return true; } @@ -1486,16 +1481,16 @@ import java.io.IOException; public long updatePeriodTrackSelection(long positionUs, boolean forceRecreateStreams, boolean[] streamResetFlags) { + TrackSelectionArray trackSelections = trackSelectorResult.selections; for (int i = 0; i < trackSelections.length; i++) { mayRetainStreamFlags[i] = !forceRecreateStreams - && Util.areEqual(periodTrackSelections == null ? null : periodTrackSelections.get(i), - trackSelections.get(i)); + && trackSelectorResult.isEquivalent(periodTrackSelectorResult, i); } // Disable streams on the period and get new streams for updated/newly-enabled tracks. positionUs = mediaPeriod.selectTracks(trackSelections.getAll(), mayRetainStreamFlags, sampleStreams, streamResetFlags, positionUs); - periodTrackSelections = trackSelections; + periodTrackSelectorResult = trackSelectorResult; // Update whether we have enabled tracks and sanity check the expected streams are non-null. hasEnabledTracks = false; @@ -1509,14 +1504,10 @@ import java.io.IOException; } // The track selection has changed. - loadControl.onTracksSelected(renderers, trackGroups, trackSelections); + loadControl.onTracksSelected(renderers, trackSelectorResult.groups, trackSelections); return positionUs; } - public TrackInfo getTrackInfo() { - return new TrackInfo(trackGroups, trackSelections, trackSelectionsInfo); - } - public void release() { try { mediaSource.releasePeriod(mediaPeriod); 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 9ac686ec34..14b52466cb 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/src/main/java/com/google/android/exoplayer2/Format.java @@ -183,20 +183,18 @@ public final class Format implements Parcelable { */ public final int accessibilityChannel; - // Lazily initialized hashcode and framework media format. - + // Lazily initialized hashcode. private int hashCode; - private MediaFormat frameworkMediaFormat; // Video. public static Format createVideoContainerFormat(String id, String containerMimeType, String sampleMimeType, String codecs, int bitrate, int width, int height, - float frameRate, List initializationData) { + float frameRate, List initializationData, @C.SelectionFlags int selectionFlags) { 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, NO_VALUE, OFFSET_SAMPLE_RELATIVE, initializationData, null, - null); + NO_VALUE, NO_VALUE, selectionFlags, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, + initializationData, null, null); } public static Format createVideoSampleFormat(String id, String sampleMimeType, String codecs, @@ -289,8 +287,8 @@ public final class Format implements Parcelable { } public static Format createTextSampleFormat(String id, String sampleMimeType, String codecs, - int bitrate, @C.SelectionFlags int selectionFlags, String language, - int accessibilityChannel, DrmInitData drmInitData) { + 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); } @@ -332,11 +330,20 @@ public final class Format implements Parcelable { // Generic. - public static Format createContainerFormat(String id, String containerMimeType, String codecs, - String sampleMimeType, int bitrate) { + public static Format createContainerFormat(String id, String containerMimeType, + String sampleMimeType, String codecs, int bitrate, @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, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, NO_VALUE, 0, null, NO_VALUE, OFFSET_SAMPLE_RELATIVE, null, null, null); + NO_VALUE, NO_VALUE, selectionFlags, language, NO_VALUE, OFFSET_SAMPLE_RELATIVE, null, null, + null); + } + + public static Format createSampleFormat(String id, String sampleMimeType, + long subsampleOffsetUs) { + return new Format(id, null, sampleMimeType, null, NO_VALUE, 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, NO_VALUE, subsampleOffsetUs, null, null, null); } public static Format createSampleFormat(String id, String sampleMimeType, String codecs, @@ -495,31 +502,28 @@ public final class Format implements Parcelable { @SuppressLint("InlinedApi") @TargetApi(16) public final MediaFormat getFrameworkMediaFormatV16() { - if (frameworkMediaFormat == null) { - MediaFormat format = new MediaFormat(); - format.setString(MediaFormat.KEY_MIME, sampleMimeType); - maybeSetStringV16(format, MediaFormat.KEY_LANGUAGE, language); - maybeSetIntegerV16(format, MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize); - maybeSetIntegerV16(format, MediaFormat.KEY_WIDTH, width); - maybeSetIntegerV16(format, MediaFormat.KEY_HEIGHT, height); - maybeSetFloatV16(format, MediaFormat.KEY_FRAME_RATE, frameRate); - maybeSetIntegerV16(format, "rotation-degrees", rotationDegrees); - maybeSetIntegerV16(format, MediaFormat.KEY_CHANNEL_COUNT, channelCount); - maybeSetIntegerV16(format, MediaFormat.KEY_SAMPLE_RATE, sampleRate); - maybeSetIntegerV16(format, "encoder-delay", encoderDelay); - maybeSetIntegerV16(format, "encoder-padding", encoderPadding); - for (int i = 0; i < initializationData.size(); i++) { - format.setByteBuffer("csd-" + i, ByteBuffer.wrap(initializationData.get(i))); - } - frameworkMediaFormat = format; + MediaFormat format = new MediaFormat(); + format.setString(MediaFormat.KEY_MIME, sampleMimeType); + maybeSetStringV16(format, MediaFormat.KEY_LANGUAGE, language); + maybeSetIntegerV16(format, MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize); + maybeSetIntegerV16(format, MediaFormat.KEY_WIDTH, width); + maybeSetIntegerV16(format, MediaFormat.KEY_HEIGHT, height); + maybeSetFloatV16(format, MediaFormat.KEY_FRAME_RATE, frameRate); + maybeSetIntegerV16(format, "rotation-degrees", rotationDegrees); + maybeSetIntegerV16(format, MediaFormat.KEY_CHANNEL_COUNT, channelCount); + maybeSetIntegerV16(format, MediaFormat.KEY_SAMPLE_RATE, sampleRate); + maybeSetIntegerV16(format, "encoder-delay", encoderDelay); + maybeSetIntegerV16(format, "encoder-padding", encoderPadding); + for (int i = 0; i < initializationData.size(); i++) { + format.setByteBuffer("csd-" + i, ByteBuffer.wrap(initializationData.get(i))); } - return frameworkMediaFormat; + return format; } @Override public String toString() { return "Format(" + id + ", " + containerMimeType + ", " + sampleMimeType + ", " + bitrate + ", " - + ", " + language + ", [" + width + ", " + height + ", " + frameRate + "]" + + language + ", [" + width + ", " + height + ", " + frameRate + "]" + ", [" + channelCount + ", " + sampleRate + "])"; } @@ -602,6 +606,38 @@ public final class Format implements Parcelable { } } + // Utility methods + + /** + * Returns a prettier {@link String} than {@link #toString()}, intended for logging. + */ + public static String toLogString(Format format) { + if (format == null) { + return "null"; + } + StringBuilder builder = new StringBuilder(); + builder.append("id=").append(format.id).append(", mimeType=").append(format.sampleMimeType); + if (format.bitrate != Format.NO_VALUE) { + builder.append(", bitrate=").append(format.bitrate); + } + if (format.width != Format.NO_VALUE && format.height != Format.NO_VALUE) { + builder.append(", res=").append(format.width).append("x").append(format.height); + } + if (format.frameRate != Format.NO_VALUE) { + builder.append(", fps=").append(format.frameRate); + } + if (format.channelCount != Format.NO_VALUE) { + builder.append(", channels=").append(format.channelCount); + } + if (format.sampleRate != Format.NO_VALUE) { + builder.append(", sample_rate=").append(format.sampleRate); + } + if (format.language != null) { + builder.append(", language=").append(format.language); + } + return builder.toString(); + } + // Parcelable implementation. @Override 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 b610a64bea..e16caec980 100644 --- a/library/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -92,6 +92,7 @@ public interface Renderer extends ExoPlayerComponent { * This method may be called when the renderer is in the following states: * {@link #STATE_DISABLED}. * + * @param configuration The renderer configuration. * @param formats The enabled formats. * @param stream The {@link SampleStream} from which the renderer should consume. * @param positionUs The player's current position. @@ -100,8 +101,8 @@ public interface Renderer extends ExoPlayerComponent { * before they are rendered. * @throws ExoPlaybackException If an error occurs. */ - void enable(Format[] formats, SampleStream stream, long positionUs, boolean joining, - long offsetUs) throws ExoPlaybackException; + void enable(RendererConfiguration configuration, Format[] formats, SampleStream stream, + long positionUs, boolean joining, long offsetUs) throws ExoPlaybackException; /** * Starts the renderer, meaning that calls to {@link #render(long, long)} will cause media to be diff --git a/library/src/main/java/com/google/android/exoplayer2/RendererCapabilities.java b/library/src/main/java/com/google/android/exoplayer2/RendererCapabilities.java index 4312bff632..151453c12c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/RendererCapabilities.java +++ b/library/src/main/java/com/google/android/exoplayer2/RendererCapabilities.java @@ -79,6 +79,20 @@ public interface RendererCapabilities { */ int ADAPTIVE_NOT_SUPPORTED = 0b0000; + /** + * A mask to apply to the result of {@link #supportsFormat(Format)} to obtain one of + * {@link #TUNNELING_SUPPORTED} and {@link #TUNNELING_NOT_SUPPORTED}. + */ + int TUNNELING_SUPPORT_MASK = 0b10000; + /** + * The {@link Renderer} supports tunneled output. + */ + int TUNNELING_SUPPORTED = 0b10000; + /** + * The {@link Renderer} does not support tunneled output. + */ + int TUNNELING_NOT_SUPPORTED = 0b00000; + /** * Returns the track type that the {@link Renderer} handles. For example, a video renderer will * return {@link C#TRACK_TYPE_VIDEO}, an audio renderer will return {@link C#TRACK_TYPE_AUDIO}, a @@ -91,7 +105,7 @@ public interface RendererCapabilities { /** * Returns the extent to which the {@link Renderer} supports a given format. The returned value is - * the bitwise OR of two properties: + * the bitwise OR of three properties: *
    *
  • The level of support for the format itself. One of {@link #FORMAT_HANDLED}, * {@link #FORMAT_EXCEEDS_CAPABILITIES}, {@link #FORMAT_UNSUPPORTED_SUBTYPE} and @@ -99,9 +113,12 @@ public interface RendererCapabilities { *
  • The level of support for adapting from the format to another format of the same mime type. * One of {@link #ADAPTIVE_SEAMLESS}, {@link #ADAPTIVE_NOT_SEAMLESS} and * {@link #ADAPTIVE_NOT_SUPPORTED}.
  • + *
  • The level of support for tunneling. One of {@link #TUNNELING_SUPPORTED} and + * {@link #TUNNELING_NOT_SUPPORTED}.
  • *
* The individual properties can be retrieved by performing a bitwise AND with - * {@link #FORMAT_SUPPORT_MASK} and {@link #ADAPTIVE_SUPPORT_MASK} respectively. + * {@link #FORMAT_SUPPORT_MASK}, {@link #ADAPTIVE_SUPPORT_MASK} and + * {@link #TUNNELING_SUPPORT_MASK} respectively. * * @param format The format. * @return The extent to which the renderer is capable of supporting the given format. diff --git a/library/src/main/java/com/google/android/exoplayer2/RendererConfiguration.java b/library/src/main/java/com/google/android/exoplayer2/RendererConfiguration.java new file mode 100644 index 0000000000..93bbd1e4b6 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/RendererConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2017 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; + +/** + * The configuration of a {@link Renderer}. + */ +public final class RendererConfiguration { + + /** + * The default configuration. + */ + public static final RendererConfiguration DEFAULT = + new RendererConfiguration(C.AUDIO_SESSION_ID_UNSET); + + /** + * The audio session id to use for tunneling, or {@link C#AUDIO_SESSION_ID_UNSET} if tunneling + * should not be enabled. + */ + public final int tunnelingAudioSessionId; + + /** + * @param tunnelingAudioSessionId The audio session id to use for tunneling, or + * {@link C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. + */ + public RendererConfiguration(int tunnelingAudioSessionId) { + this.tunnelingAudioSessionId = tunnelingAudioSessionId; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + RendererConfiguration other = (RendererConfiguration) obj; + return tunnelingAudioSessionId == other.tunnelingAudioSessionId; + } + + @Override + public int hashCode() { + return tunnelingAudioSessionId; + } + +} 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 73df6a1e7a..da9417374e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -36,7 +36,6 @@ 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.source.MediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; @@ -448,15 +447,6 @@ public class SimpleExoPlayer implements ExoPlayer { textOutput = output; } - /** - * @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. * @@ -555,6 +545,36 @@ public class SimpleExoPlayer implements ExoPlayer { player.blockingSendMessages(messages); } + @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(); + } + + @Override + public Object getCurrentManifest() { + return player.getCurrentManifest(); + } + @Override public int getCurrentPeriodIndex() { return player.getCurrentPeriodIndex(); @@ -586,33 +606,13 @@ public class SimpleExoPlayer implements ExoPlayer { } @Override - public int getRendererCount() { - return player.getRendererCount(); + public boolean isCurrentWindowDynamic() { + return player.isCurrentWindowDynamic(); } @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(); - } - - @Override - public Object getCurrentManifest() { - return player.getCurrentManifest(); + public boolean isCurrentWindowSeekable() { + return player.isCurrentWindowSeekable(); } // Renderer building. @@ -771,7 +771,7 @@ public class SimpleExoPlayer implements ExoPlayer { protected void buildMetadataRenderers(Context context, Handler mainHandler, @ExtensionRendererMode int extensionRendererMode, MetadataRenderer.Output output, ArrayList out) { - out.add(new MetadataRenderer(output, mainHandler.getLooper(), new Id3Decoder())); + out.add(new MetadataRenderer(output, mainHandler.getLooper())); } /** 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 072180db94..b5873904fc 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 @@ -38,21 +38,21 @@ import java.nio.ByteOrder; * playback position smoothing, non-blocking writes and reconfiguration. *

* Before starting playback, specify the input format by calling - * {@link #configure(String, int, int, int, int)}. Next call {@link #initialize(int)} or - * {@link #initializeV21(int, boolean)}, optionally specifying an audio session and whether the - * track is to be used with tunneling video playback. + * {@link #configure(String, int, int, int, int)}. Optionally call {@link #setAudioSessionId(int)}, + * {@link #setStreamType(int)}, {@link #enableTunnelingV21(int)} and {@link #disableTunneling()} + * to configure audio playback. These methods may be called after writing data to the track, in + * which case it will be reinitialized as required. *

* Call {@link #handleBuffer(ByteBuffer, long)} to write data, and {@link #handleDiscontinuity()} * when the data being fed is discontinuous. Call {@link #play()} to start playing the written data. *

- * Call {@link #configure(String, int, int, int, int)} whenever the input format changes. If - * {@link #isInitialized()} returns {@code false} after the call, it is necessary to call - * {@link #initialize(int)} or {@link #initializeV21(int, boolean)} before writing more data. + * Call {@link #configure(String, int, int, int, int)} whenever the input format changes. The track + * will be reinitialized on the next call to {@link #handleBuffer(ByteBuffer, long)}. *

- * The underlying {@link android.media.AudioTrack} is created by {@link #initialize(int)} and - * released by {@link #reset()} (and {@link #configure(String, int, int, int, int)} unless the input - * format is unchanged). It is safe to call {@link #initialize(int)} or - * {@link #initializeV21(int, boolean)} after calling {@link #reset()} without reconfiguration. + * Calling {@link #reset()} releases the underlying {@link android.media.AudioTrack} (and so does + * calling {@link #configure(String, int, int, int, int)} unless the format is unchanged). It is + * safe to call {@link #handleBuffer(ByteBuffer, long)} after {@link #reset()} without calling + * {@link #configure(String, int, int, int, int)}. *

* Call {@link #release()} when the instance is no longer required. */ @@ -63,6 +63,19 @@ public final class AudioTrack { */ public interface Listener { + /** + * Called when the audio track has been initialized with a newly generated audio session id. + * + * @param audioSessionId The newly generated audio session id. + */ + void onAudioSessionId(int audioSessionId); + + /** + * Called when the audio track handles a buffer whose timestamp is discontinuous with the last + * buffer handled since it was reset. + */ + void onPositionDiscontinuity(); + /** * Called when the audio track underruns. * @@ -137,15 +150,6 @@ public final class AudioTrack { } - /** - * Returned in the result of {@link #handleBuffer} if the buffer was discontinuous. - */ - public static final int RESULT_POSITION_DISCONTINUITY = 1; - /** - * Returned in the result of {@link #handleBuffer} if the buffer can be released. - */ - public static final int RESULT_BUFFER_CONSUMED = 2; - /** * Returned by {@link #getCurrentPositionUs} when the position is not set. */ @@ -253,7 +257,7 @@ public final class AudioTrack { private final AudioTrackUtil audioTrackUtil; /** - * Used to keep the audio session active on pre-V21 builds (see {@link #initialize(int)}). + * Used to keep the audio session active on pre-V21 builds (see {@link #initialize()}). */ private android.media.AudioTrack keepSessionIdAudioTrack; @@ -271,7 +275,6 @@ public final class AudioTrack { private int bufferSize; private long bufferSizeUs; - private boolean useHwAvSync; private ByteBuffer avSyncHeader; private int bytesUntilNextAvSync; @@ -299,6 +302,9 @@ public final class AudioTrack { private ByteBuffer resampledBuffer; private boolean useResampledBuffer; + private boolean playing; + private int audioSessionId; + private boolean tunneling; private boolean hasData; private long lastFeedElapsedRealtimeMs; @@ -329,6 +335,7 @@ public final class AudioTrack { volume = 1.0f; startMediaTimeState = START_NOT_SET; streamType = C.STREAM_TYPE_DEFAULT; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; } /** @@ -342,14 +349,6 @@ public final class AudioTrack { && audioCapabilities.supportsEncoding(getEncodingForMimeType(mimeType)); } - /** - * Returns whether the audio track has been successfully initialized via {@link #initialize} or - * {@link #initializeV21(int, boolean)}, and has not yet been {@link #reset}. - */ - public boolean isInitialized() { - return audioTrack != null; - } - /** * Returns the playback position in the stream starting at zero, in microseconds, or * {@link #CURRENT_POSITION_NOT_SET} if it is not yet available. @@ -446,7 +445,7 @@ public final class AudioTrack { // Workaround for overly strict channel configuration checks on nVidia Shield. if (Util.SDK_INT <= 23 && "foster".equals(Util.DEVICE) && "NVIDIA".equals(Util.MANUFACTURER)) { - switch(channelCount) { + switch (channelCount) { case 7: channelConfig = C.CHANNEL_OUT_7POINT1_SURROUND; break; @@ -460,6 +459,13 @@ public final class AudioTrack { } boolean passthrough = !MimeTypes.AUDIO_RAW.equals(mimeType); + + // Workaround for Nexus Player not reporting support for mono passthrough. + // (See [Internal: b/34268671].) + if (Util.SDK_INT <= 25 && "fugu".equals(Util.DEVICE) && passthrough && channelCount == 1) { + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + } + @C.Encoding int sourceEncoding; if (passthrough) { sourceEncoding = getEncodingForMimeType(mimeType); @@ -512,31 +518,7 @@ public final class AudioTrack { bufferSizeUs = passthrough ? C.TIME_UNSET : framesToDurationUs(pcmBytesToFrames(bufferSize)); } - /** - * Initializes the audio track for writing new buffers using {@link #handleBuffer}. - * - * @param sessionId Audio track session identifier, or {@link C#AUDIO_SESSION_ID_UNSET} to create - * one. - * @return The audio track session identifier. - */ - public int initialize(int sessionId) throws InitializationException { - return initializeInternal(sessionId, false); - } - - /** - * Initializes the audio track for writing new buffers using {@link #handleBuffer}. - * - * @param sessionId Audio track session identifier, or {@link C#AUDIO_SESSION_ID_UNSET} to create - * one. - * @param tunneling Whether the audio track is to be used with tunneling video playback. - * @return The audio track session identifier. - */ - public int initializeV21(int sessionId, boolean tunneling) throws InitializationException { - Assertions.checkState(Util.SDK_INT >= 21); - return initializeInternal(sessionId, tunneling); - } - - private int initializeInternal(int sessionId, boolean tunneling) throws InitializationException { + private void initialize() throws InitializationException { // If we're asynchronously releasing a previous audio track then we block until it has been // released. This guarantees that we cannot end up in a state where we have multiple audio // track instances. Without this guarantee it would be possible, in extreme cases, to exhaust @@ -544,27 +526,26 @@ public final class AudioTrack { // initialization of the audio track to fail. releasingConditionVariable.block(); - useHwAvSync = tunneling; - if (useHwAvSync) { + if (tunneling) { audioTrack = createHwAvSyncAudioTrackV21(sampleRate, channelConfig, targetEncoding, - bufferSize, sessionId); - } else if (sessionId == C.AUDIO_SESSION_ID_UNSET) { + bufferSize, audioSessionId); + } else if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { audioTrack = new android.media.AudioTrack(streamType, sampleRate, channelConfig, targetEncoding, bufferSize, MODE_STREAM); } else { // Re-attach to the same audio session. audioTrack = new android.media.AudioTrack(streamType, sampleRate, channelConfig, - targetEncoding, bufferSize, MODE_STREAM, sessionId); + targetEncoding, bufferSize, MODE_STREAM, audioSessionId); } checkAudioTrackInitialized(); - sessionId = audioTrack.getAudioSessionId(); + int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { // The workaround creates an audio track with a two byte buffer on the same session, and // does not release it until this object is released, which keeps the session active. if (keepSessionIdAudioTrack != null - && sessionId != keepSessionIdAudioTrack.getAudioSessionId()) { + && audioSessionId != keepSessionIdAudioTrack.getAudioSessionId()) { releaseKeepSessionIdAudioTrack(); } if (keepSessionIdAudioTrack == null) { @@ -573,21 +554,25 @@ 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, MODE_STATIC, sessionId); + channelConfig, encoding, bufferSize, MODE_STATIC, audioSessionId); } } } + if (this.audioSessionId != audioSessionId) { + this.audioSessionId = audioSessionId; + listener.onAudioSessionId(audioSessionId); + } audioTrackUtil.reconfigure(audioTrack, needsPassthroughWorkarounds()); - setAudioTrackVolume(); + setVolumeInternal(); hasData = false; - return sessionId; } /** * Starts or resumes playing audio if the audio track has been initialized. */ public void play() { + playing = true; if (isInitialized()) { resumeSystemTimeUs = System.nanoTime() / 1000; audioTrack.play(); @@ -608,35 +593,41 @@ public final class AudioTrack { * Attempts to write data from a {@link ByteBuffer} to the audio track, starting from its current * position and ending at its limit (exclusive). The position of the {@link ByteBuffer} is * advanced by the number of bytes that were successfully written. + * {@link Listener#onPositionDiscontinuity()} will be called if {@code presentationTimeUs} is + * discontinuous with the last buffer handled since the track was reset. *

- * Returns a bit field containing {@link #RESULT_BUFFER_CONSUMED} if the data was written in full, - * and {@link #RESULT_POSITION_DISCONTINUITY} if the buffer was discontinuous with previously - * written data. - *

- * If the data was not written in full then the same {@link ByteBuffer} must be provided to - * subsequent calls until it has been fully consumed, except in the case of an interleaving call - * to {@link #configure} or {@link #reset}. + * Returns whether the data was written in full. If the data was not written in full then the same + * {@link ByteBuffer} must be provided to subsequent calls until it has been fully consumed, + * except in the case of an interleaving call to {@link #reset()} (or an interleaving call to + * {@link #configure(String, int, int, int, int)} that caused the track to be reset). * * @param buffer The buffer containing audio data to play back. * @param presentationTimeUs Presentation timestamp of the next buffer in microseconds. - * @return A bit field with {@link #RESULT_BUFFER_CONSUMED} if the buffer can be released, and - * {@link #RESULT_POSITION_DISCONTINUITY} if the buffer was not contiguous with previously - * written data. + * @return Whether the buffer was consumed fully. + * @throws InitializationException If an error occurs initializing the track. * @throws WriteException If an error occurs writing the audio data. */ - public int handleBuffer(ByteBuffer buffer, long presentationTimeUs) throws WriteException { + public boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException { + if (!isInitialized()) { + initialize(); + if (playing) { + play(); + } + } + 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); + boolean result = writeBuffer(buffer, presentationTimeUs); lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); return result; } - private int writeBuffer(ByteBuffer buffer, long presentationTimeUs) throws WriteException { + private boolean writeBuffer(ByteBuffer buffer, long presentationTimeUs) throws WriteException { boolean isNewSourceBuffer = currentSourceBuffer == null; Assertions.checkState(isNewSourceBuffer || currentSourceBuffer == buffer); currentSourceBuffer = buffer; @@ -645,7 +636,7 @@ public final class AudioTrack { // 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() == PLAYSTATE_PAUSED) { - return 0; + return false; } // A new AC-3 audio track's playback position continues to increase from the old track's @@ -653,18 +644,17 @@ public final class AudioTrack { // head position actually returns to zero. if (audioTrack.getPlayState() == PLAYSTATE_STOPPED && audioTrackUtil.getPlaybackHeadPosition() != 0) { - return 0; + return false; } } - int result = 0; if (isNewSourceBuffer) { // We're seeing this buffer for the first time. if (!currentSourceBuffer.hasRemaining()) { // The buffer is empty. currentSourceBuffer = null; - return RESULT_BUFFER_CONSUMED; + return true; } useResampledBuffer = targetEncoding != sourceEncoding; @@ -697,7 +687,7 @@ public final class AudioTrack { // number of bytes submitted. startMediaTimeUs += (presentationTimeUs - expectedPresentationTimeUs); startMediaTimeState = START_IN_SYNC; - result |= RESULT_POSITION_DISCONTINUITY; + listener.onPositionDiscontinuity(); } } if (Util.SDK_INT < 21) { @@ -730,7 +720,7 @@ public final class AudioTrack { buffer.position(buffer.position() + bytesWritten); } } else { - bytesWritten = useHwAvSync + bytesWritten = tunneling ? writeNonBlockingWithAvSyncV21(audioTrack, buffer, bytesRemaining, presentationTimeUs) : writeNonBlockingV21(audioTrack, buffer, bytesRemaining); } @@ -747,9 +737,9 @@ public final class AudioTrack { submittedEncodedFrames += framesPerEncodedSample; } currentSourceBuffer = null; - result |= RESULT_BUFFER_CONSUMED; + return true; } - return result; + return false; } /** @@ -785,28 +775,64 @@ public final class AudioTrack { /** * Sets the stream type for audio track. If the stream type has changed and if the audio track - * is not configured for use with video tunneling, then the audio track is reset and the caller - * must re-initialize the audio track before writing more data. The caller must not reuse the - * audio session identifier when re-initializing with a new stream type. + * is not configured for use with tunneling, then the audio track is reset and the audio session + * id is cleared. *

- * If the audio track is configured for use with video tunneling then the stream type is ignored - * and the audio track is not reset. The passed stream type will be used if the audio track is - * later re-configured into non-tunneled mode. + * If the audio track is configured for use with tunneling then the stream type is ignored, the + * audio track is not reset and the audio session id is not cleared. The passed stream type will + * be used if the audio track is later re-configured into non-tunneled mode. * * @param streamType The {@link C.StreamType} to use for audio output. - * @return Whether the audio track was reset as a result of this call. */ - public boolean setStreamType(@C.StreamType int streamType) { + public void setStreamType(@C.StreamType int streamType) { if (this.streamType == streamType) { - return false; + return; } this.streamType = streamType; - if (useHwAvSync) { + if (tunneling) { // The stream type is ignored in tunneling mode, so no need to reset. - return false; + return; } reset(); - return true; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + } + + /** + * Sets the audio session id. The audio track is reset if the audio session id has changed. + */ + public void setAudioSessionId(int audioSessionId) { + if (this.audioSessionId != audioSessionId) { + this.audioSessionId = audioSessionId; + reset(); + } + } + + /** + * Enables tunneling. The audio track is reset if tunneling was previously disabled or if the + * audio session id has changed. Enabling tunneling requires platform API version 21 onwards. + * + * @param tunnelingAudioSessionId The audio session id to use. + * @throws IllegalStateException Thrown if enabling tunneling on platform API version < 21. + */ + public void enableTunnelingV21(int tunnelingAudioSessionId) { + Assertions.checkState(Util.SDK_INT >= 21); + if (!tunneling || audioSessionId != tunnelingAudioSessionId) { + tunneling = true; + audioSessionId = tunnelingAudioSessionId; + reset(); + } + } + + /** + * Disables tunneling. If tunneling was previously enabled then the audio track is reset and the + * audio session id is cleared. + */ + public void disableTunneling() { + if (tunneling) { + tunneling = false; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + reset(); + } } /** @@ -817,17 +843,17 @@ public final class AudioTrack { public void setVolume(float volume) { if (this.volume != volume) { this.volume = volume; - setAudioTrackVolume(); + setVolumeInternal(); } } - private void setAudioTrackVolume() { + private void setVolumeInternal() { if (!isInitialized()) { // Do nothing. } else if (Util.SDK_INT >= 21) { - setAudioTrackVolumeV21(audioTrack, volume); + setVolumeInternalV21(audioTrack, volume); } else { - setAudioTrackVolumeV3(audioTrack, volume); + setVolumeInternalV3(audioTrack, volume); } } @@ -835,6 +861,7 @@ public final class AudioTrack { * Pauses playback. */ public void pause() { + playing = false; if (isInitialized()) { resetSyncParams(); audioTrackUtil.pause(); @@ -844,9 +871,9 @@ public final class AudioTrack { /** * Releases the underlying audio track asynchronously. *

- * Calling {@link #initialize(int)} or {@link #initializeV21(int, boolean)} will block until the - * audio track has been released, so it is safe to initialize immediately after a reset. The audio - * session may remain active until {@link #release()} is called. + * Calling {@link #handleBuffer(ByteBuffer, long)} will block until the audio track has been + * released, so it is safe to use the audio track immediately after a reset. The audio session may + * remain active until {@link #release()} is called. */ public void reset() { if (isInitialized()) { @@ -855,6 +882,7 @@ public final class AudioTrack { framesPerEncodedSample = 0; currentSourceBuffer = null; avSyncHeader = null; + bytesUntilNextAvSync = 0; startMediaTimeState = START_NOT_SET; latencyUs = 0; resetSyncParams(); @@ -887,6 +915,8 @@ public final class AudioTrack { public void release() { reset(); releaseKeepSessionIdAudioTrack(); + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + playing = false; } /** @@ -1024,6 +1054,10 @@ public final class AudioTrack { throw new InitializationException(state, sampleRate, channelConfig, bufferSize); } + private boolean isInitialized() { + return audioTrack != null; + } + private long pcmBytesToFrames(long byteCount) { return byteCount / pcmFrameSize; } @@ -1240,12 +1274,12 @@ public final class AudioTrack { } @TargetApi(21) - private static void setAudioTrackVolumeV21(android.media.AudioTrack audioTrack, float volume) { + private static void setVolumeInternalV21(android.media.AudioTrack audioTrack, float volume) { audioTrack.setVolume(volume); } @SuppressWarnings("deprecation") - private static void setAudioTrackVolumeV3(android.media.AudioTrack audioTrack, float volume) { + private static void setVolumeInternalV3(android.media.AudioTrack audioTrack, float volume) { audioTrack.setStereoVolume(volume, volume); } @@ -1494,7 +1528,7 @@ public final class AudioTrack { playbackParams = (playbackParams != null ? playbackParams : new PlaybackParams()) .allowDefaults(); this.playbackParams = playbackParams; - this.playbackSpeed = playbackParams.getSpeed(); + playbackSpeed = playbackParams.getSpeed(); maybeApplyPlaybackParams(); } 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 d3cde10afb..b4813d90a2 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 @@ -41,8 +41,7 @@ import java.nio.ByteBuffer; * Decodes and renders audio using {@link MediaCodec} and {@link AudioTrack}. */ @TargetApi(16) -public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock, - AudioTrack.Listener { +public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock { private final EventDispatcher eventDispatcher; private final AudioTrack audioTrack; @@ -50,7 +49,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private boolean passthroughEnabled; private android.media.MediaFormat passthroughMediaFormat; private int pcmEncoding; - private int audioSessionId; private long currentPositionUs; private boolean allowPositionDiscontinuity; @@ -129,8 +127,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media boolean playClearSamplesWithoutKeys, Handler eventHandler, AudioRendererEventListener eventListener, AudioCapabilities audioCapabilities) { super(C.TRACK_TYPE_AUDIO, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys); - audioSessionId = C.AUDIO_SESSION_ID_UNSET; - audioTrack = new AudioTrack(audioCapabilities, this); + audioTrack = new AudioTrack(audioCapabilities, new AudioTrackListener()); eventDispatcher = new EventDispatcher(eventHandler, eventListener); } @@ -141,10 +138,11 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media if (!MimeTypes.isAudio(mimeType)) { return FORMAT_UNSUPPORTED_TYPE; } + int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; if (allowPassthrough(mimeType) && mediaCodecSelector.getPassthroughDecoderInfo() != null) { - return ADAPTIVE_NOT_SEAMLESS | FORMAT_HANDLED; + return ADAPTIVE_NOT_SEAMLESS | tunnelingSupport | FORMAT_HANDLED; } - MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, false, false); + MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, false); if (decoderInfo == null) { return FORMAT_UNSUPPORTED_SUBTYPE; } @@ -155,7 +153,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media && (format.channelCount == Format.NO_VALUE || decoderInfo.isAudioChannelCountSupportedV21(format.channelCount))); int formatSupport = decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES; - return ADAPTIVE_NOT_SEAMLESS | formatSupport; + return ADAPTIVE_NOT_SEAMLESS | tunnelingSupport | formatSupport; } @Override @@ -231,25 +229,42 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } /** - * Called when the audio session id becomes known. Once the id is known it will not change (and - * hence this method will not be called again) unless the renderer is disabled and then - * subsequently re-enabled. - *

- * The default implementation is a no-op. One reason for overriding this method would be to - * instantiate and enable a {@link Virtualizer} in order to spatialize the audio channels. For - * this use case, any {@link Virtualizer} instances should be released in {@link #onDisabled()} - * (if not before). + * Called when the audio session id becomes known. The default implementation is a no-op. One + * reason for overriding this method would be to instantiate and enable a {@link Virtualizer} in + * order to spatialize the audio channels. For this use case, any {@link Virtualizer} instances + * should be released in {@link #onDisabled()} (if not before). * - * @param audioSessionId The audio session id. + * @see AudioTrack.Listener#onAudioSessionId(int) */ protected void onAudioSessionId(int audioSessionId) { // Do nothing. } + /** + * @see AudioTrack.Listener#onPositionDiscontinuity() + */ + protected void onAudioTrackPositionDiscontinuity() { + // Do nothing. + } + + /** + * @see AudioTrack.Listener#onUnderrun(int, long, long) + */ + protected void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, + long elapsedSinceLastFeedMs) { + // Do nothing. + } + @Override protected void onEnabled(boolean joining) throws ExoPlaybackException { super.onEnabled(joining); eventDispatcher.enabled(decoderCounters); + int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + audioTrack.enableTunnelingV21(tunnelingAudioSessionId); + } else { + audioTrack.disableTunneling(); + } } @Override @@ -274,7 +289,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void onDisabled() { - audioSessionId = C.AUDIO_SESSION_ID_UNSET; try { audioTrack.release(); } finally { @@ -325,44 +339,15 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return true; } - if (!audioTrack.isInitialized()) { - // Initialize the AudioTrack now. - try { - if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { - audioSessionId = audioTrack.initialize(C.AUDIO_SESSION_ID_UNSET); - eventDispatcher.audioSessionId(audioSessionId); - onAudioSessionId(audioSessionId); - } else { - audioTrack.initialize(audioSessionId); - } - } catch (AudioTrack.InitializationException e) { - throw ExoPlaybackException.createForRenderer(e, getIndex()); - } - if (getState() == STATE_STARTED) { - audioTrack.play(); - } - } - - int handleBufferResult; try { - handleBufferResult = audioTrack.handleBuffer(buffer, bufferPresentationTimeUs); - } catch (AudioTrack.WriteException e) { + if (audioTrack.handleBuffer(buffer, bufferPresentationTimeUs)) { + codec.releaseOutputBuffer(bufferIndex, false); + decoderCounters.renderedOutputBufferCount++; + return true; + } + } catch (AudioTrack.InitializationException | AudioTrack.WriteException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } - - // If we are out of sync, allow currentPositionUs to jump backwards. - if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) { - handleAudioTrackDiscontinuity(); - allowPositionDiscontinuity = true; - } - - // Release the buffer if it was consumed. - if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) { - codec.releaseOutputBuffer(bufferIndex, false); - decoderCounters.renderedOutputBufferCount++; - return true; - } - return false; } @@ -371,10 +356,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media audioTrack.handleEndOfStream(); } - protected void handleAudioTrackDiscontinuity() { - // Do nothing - } - @Override public void handleMessage(int messageType, Object message) throws ExoPlaybackException { switch (messageType) { @@ -386,9 +367,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media break; case C.MSG_SET_STREAM_TYPE: @C.StreamType int streamType = (Integer) message; - if (audioTrack.setStreamType(streamType)) { - audioSessionId = C.AUDIO_SESSION_ID_UNSET; - } + audioTrack.setStreamType(streamType); break; default: super.handleMessage(messageType, message); @@ -396,11 +375,27 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } } - // AudioTrack.Listener implementation. + private final class AudioTrackListener implements AudioTrack.Listener { + + @Override + public void onAudioSessionId(int audioSessionId) { + eventDispatcher.audioSessionId(audioSessionId); + MediaCodecAudioRenderer.this.onAudioSessionId(audioSessionId); + } + + @Override + public void onPositionDiscontinuity() { + onAudioTrackPositionDiscontinuity(); + // We are out of sync so allow currentPositionUs to jump backwards. + MediaCodecAudioRenderer.this.allowPositionDiscontinuity = true; + } + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } - @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 5c9acc7739..d23ee769dd 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.audio; import android.media.PlaybackParams; +import android.media.audiofx.Virtualizer; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; @@ -43,8 +44,7 @@ import java.lang.annotation.RetentionPolicy; /** * Decodes and renders audio using a {@link SimpleDecoder}. */ -public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements MediaClock, - AudioTrack.Listener { +public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements MediaClock { @Retention(RetentionPolicy.SOURCE) @IntDef({REINITIALIZATION_STATE_NONE, REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM, @@ -94,8 +94,6 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements private boolean outputStreamEnded; private boolean waitingForKeys; - private int audioSessionId; - public SimpleDecoderAudioRenderer() { this(null, null); } @@ -141,11 +139,10 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys) { super(C.TRACK_TYPE_AUDIO); eventDispatcher = new EventDispatcher(eventHandler, eventListener); - audioTrack = new AudioTrack(audioCapabilities, this); + audioTrack = new AudioTrack(audioCapabilities, new AudioTrackListener()); this.drmSessionManager = drmSessionManager; formatHolder = new FormatHolder(); this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; - audioSessionId = C.AUDIO_SESSION_ID_UNSET; decoderReinitializationState = REINITIALIZATION_STATE_NONE; audioTrackNeedsConfigure = true; } @@ -155,6 +152,25 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return this; } + @Override + public final int supportsFormat(Format format) { + int formatSupport = supportsFormatInternal(format); + if (formatSupport == FORMAT_UNSUPPORTED_TYPE || formatSupport == FORMAT_UNSUPPORTED_SUBTYPE) { + return formatSupport; + } + int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + return ADAPTIVE_NOT_SEAMLESS | tunnelingSupport | formatSupport; + } + + /** + * Returns the {@link #FORMAT_SUPPORT_MASK} component of the return value for + * {@link #supportsFormat(Format)}. + * + * @param format The format. + * @return The extent to which the renderer supports the format itself. + */ + protected abstract int supportsFormatInternal(Format format); + @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { if (outputStreamEnded) { @@ -185,6 +201,33 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } } + /** + * Called when the audio session id becomes known. The default implementation is a no-op. One + * reason for overriding this method would be to instantiate and enable a {@link Virtualizer} in + * order to spatialize the audio channels. For this use case, any {@link Virtualizer} instances + * should be released in {@link #onDisabled()} (if not before). + * + * @see AudioTrack.Listener#onAudioSessionId(int) + */ + protected void onAudioSessionId(int audioSessionId) { + // Do nothing. + } + + /** + * @see AudioTrack.Listener#onPositionDiscontinuity() + */ + protected void onAudioTrackPositionDiscontinuity() { + // Do nothing. + } + + /** + * @see AudioTrack.Listener#onUnderrun(int, long, long) + */ + protected void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, + long elapsedSinceLastFeedMs) { + // Do nothing. + } + /** * Creates a decoder for the given format. * @@ -244,28 +287,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements audioTrackNeedsConfigure = false; } - if (!audioTrack.isInitialized()) { - if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { - audioSessionId = audioTrack.initialize(C.AUDIO_SESSION_ID_UNSET); - eventDispatcher.audioSessionId(audioSessionId); - onAudioSessionId(audioSessionId); - } else { - audioTrack.initialize(audioSessionId); - } - if (getState() == STATE_STARTED) { - audioTrack.play(); - } - } - - int handleBufferResult = audioTrack.handleBuffer(outputBuffer.data, outputBuffer.timeUs); - - // If we are out of sync, allow currentPositionUs to jump backwards. - if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) { - allowPositionDiscontinuity = true; - } - - // Release the buffer if it was consumed. - if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) { + if (audioTrack.handleBuffer(outputBuffer.data, outputBuffer.timeUs)) { decoderCounters.renderedOutputBufferCount++; outputBuffer.release(); outputBuffer = null; @@ -381,23 +403,16 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return currentPositionUs; } - /** - * Called when the audio session id becomes known. Once the id is known it will not change (and - * hence this method will not be called again) unless the renderer is disabled and then - * subsequently re-enabled. - *

- * The default implementation is a no-op. - * - * @param audioSessionId The audio session id. - */ - protected void onAudioSessionId(int audioSessionId) { - // Do nothing. - } - @Override protected void onEnabled(boolean joining) throws ExoPlaybackException { decoderCounters = new DecoderCounters(); eventDispatcher.enabled(decoderCounters); + int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + audioTrack.enableTunnelingV21(tunnelingAudioSessionId); + } else { + audioTrack.disableTunneling(); + } } @Override @@ -425,7 +440,6 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements @Override protected void onDisabled() { inputFormat = null; - audioSessionId = C.AUDIO_SESSION_ID_UNSET; audioTrackNeedsConfigure = true; waitingForKeys = false; try { @@ -537,6 +551,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements // There aren't any final output buffers, so release the decoder immediately. releaseDecoder(); maybeInitDecoder(); + audioTrackNeedsConfigure = true; } eventDispatcher.inputFormatChanged(newFormat); @@ -553,9 +568,7 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements break; case C.MSG_SET_STREAM_TYPE: @C.StreamType int streamType = (Integer) message; - if (audioTrack.setStreamType(streamType)) { - audioSessionId = C.AUDIO_SESSION_ID_UNSET; - } + audioTrack.setStreamType(streamType); break; default: super.handleMessage(messageType, message); @@ -563,11 +576,27 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } } - // AudioTrack.Listener implementation. + private final class AudioTrackListener implements AudioTrack.Listener { + + @Override + public void onAudioSessionId(int audioSessionId) { + eventDispatcher.audioSessionId(audioSessionId); + SimpleDecoderAudioRenderer.this.onAudioSessionId(audioSessionId); + } + + @Override + public void onPositionDiscontinuity() { + onAudioTrackPositionDiscontinuity(); + // We are out of sync so allow currentPositionUs to jump backwards. + SimpleDecoderAudioRenderer.this.allowPositionDiscontinuity = true; + } + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } - @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/StreamingDrmSessionManager.java b/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java similarity index 63% rename from library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java rename to library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 4e4845c70b..9c959a38c5 100644 --- a/library/src/main/java/com/google/android/exoplayer2/drm/StreamingDrmSessionManager.java +++ b/library/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -24,7 +24,10 @@ import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; +import android.support.annotation.IntDef; import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; @@ -33,18 +36,21 @@ import com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; import com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.HashMap; +import java.util.Map; import java.util.UUID; /** - * A {@link DrmSessionManager} that supports streaming playbacks using {@link MediaDrm}. + * A {@link DrmSessionManager} that supports playbacks using {@link MediaDrm}. */ @TargetApi(18) -public class StreamingDrmSessionManager implements DrmSessionManager, +public class DefaultDrmSessionManager implements DrmSessionManager, DrmSession { /** - * Listener of {@link StreamingDrmSessionManager} events. + * Listener of {@link DefaultDrmSessionManager} events. */ public interface EventListener { @@ -60,6 +66,16 @@ public class StreamingDrmSessionManager implements Drm */ void onDrmSessionManagerError(Exception e); + /** + * Called each time offline keys are restored. + */ + void onDrmKeysRestored(); + + /** + * Called each time offline keys are removed. + */ + void onDrmKeysRemoved(); + } /** @@ -67,9 +83,32 @@ public class StreamingDrmSessionManager implements Drm */ public static final String PLAYREADY_CUSTOM_DATA_KEY = "PRCustomData"; + /** Determines the action to be done after a session acquired. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({MODE_PLAYBACK, MODE_QUERY, MODE_DOWNLOAD, MODE_RELEASE}) + public @interface Mode {} + /** + * Loads and refreshes (if necessary) a license for playback. Supports streaming and offline + * licenses. + */ + public static final int MODE_PLAYBACK = 0; + /** + * Restores an offline license to allow its status to be queried. If the offline license is + * expired sets state to {@link #STATE_ERROR}. + */ + public static final int MODE_QUERY = 1; + /** Downloads an offline license or renews an existing one. */ + public static final int MODE_DOWNLOAD = 2; + /** Releases an existing offline license. */ + public static final int MODE_RELEASE = 3; + + private static final String TAG = "OfflineDrmSessionMngr"; + private static final int MSG_PROVISION = 0; private static final int MSG_KEYS = 1; + private static final int MAX_LICENSE_DURATION_TO_RENEW = 60; + private final Handler eventHandler; private final EventListener eventListener; private final ExoMediaDrm mediaDrm; @@ -85,14 +124,17 @@ public class StreamingDrmSessionManager implements Drm private HandlerThread requestHandlerThread; private Handler postRequestHandler; + private int mode; private int openCount; private boolean provisioningInProgress; @DrmSession.State private int state; private T mediaCrypto; - private Exception lastException; - private SchemeData schemeData; + private DrmSessionException lastException; + private byte[] schemeInitData; + private String schemeMimeType; private byte[] sessionId; + private byte[] offlineLicenseKeySetId; /** * Instantiates a new instance using the Widevine scheme. @@ -105,7 +147,7 @@ public class StreamingDrmSessionManager implements Drm * @param eventListener A listener of events. May be null if delivery of events is not required. * @throws UnsupportedDrmException If the specified DRM scheme is not supported. */ - public static StreamingDrmSessionManager newWidevineInstance( + public static DefaultDrmSessionManager newWidevineInstance( MediaDrmCallback callback, HashMap optionalKeyRequestParameters, Handler eventHandler, EventListener eventListener) throws UnsupportedDrmException { return newFrameworkInstance(C.WIDEVINE_UUID, callback, optionalKeyRequestParameters, @@ -125,7 +167,7 @@ public class StreamingDrmSessionManager implements Drm * @param eventListener A listener of events. May be null if delivery of events is not required. * @throws UnsupportedDrmException If the specified DRM scheme is not supported. */ - public static StreamingDrmSessionManager newPlayReadyInstance( + public static DefaultDrmSessionManager newPlayReadyInstance( MediaDrmCallback callback, String customData, Handler eventHandler, EventListener eventListener) throws UnsupportedDrmException { HashMap optionalKeyRequestParameters; @@ -151,10 +193,10 @@ public class StreamingDrmSessionManager implements Drm * @param eventListener A listener of events. May be null if delivery of events is not required. * @throws UnsupportedDrmException If the specified DRM scheme is not supported. */ - public static StreamingDrmSessionManager newFrameworkInstance( + public static DefaultDrmSessionManager newFrameworkInstance( UUID uuid, MediaDrmCallback callback, HashMap optionalKeyRequestParameters, Handler eventHandler, EventListener eventListener) throws UnsupportedDrmException { - return new StreamingDrmSessionManager<>(uuid, FrameworkMediaDrm.newInstance(uuid), callback, + return new DefaultDrmSessionManager<>(uuid, FrameworkMediaDrm.newInstance(uuid), callback, optionalKeyRequestParameters, eventHandler, eventListener); } @@ -168,7 +210,7 @@ public class StreamingDrmSessionManager implements Drm * 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 StreamingDrmSessionManager(UUID uuid, ExoMediaDrm mediaDrm, MediaDrmCallback callback, + public DefaultDrmSessionManager(UUID uuid, ExoMediaDrm mediaDrm, MediaDrmCallback callback, HashMap optionalKeyRequestParameters, Handler eventHandler, EventListener eventListener) { this.uuid = uuid; @@ -179,6 +221,7 @@ public class StreamingDrmSessionManager implements Drm this.eventListener = eventListener; mediaDrm.setOnEventListener(new MediaDrmEventListener()); state = STATE_CLOSED; + mode = MODE_PLAYBACK; } /** @@ -229,6 +272,35 @@ public class StreamingDrmSessionManager implements Drm mediaDrm.setPropertyByteArray(key, value); } + /** + * Sets the mode, which determines the role of sessions acquired from the instance. This must be + * called before {@link #acquireSession(Looper, DrmInitData)} is called. + * + *

By default, the mode is {@link #MODE_PLAYBACK} and a streaming license is requested when + * required. + * + *

{@code mode} must be one of these: + *

  • {@link #MODE_PLAYBACK}: If {@code offlineLicenseKeySetId} is null, a streaming license is + * requested otherwise the offline license is restored. + *
  • {@link #MODE_QUERY}: {@code offlineLicenseKeySetId} can not be null. The offline license + * is restored. + *
  • {@link #MODE_DOWNLOAD}: If {@code offlineLicenseKeySetId} is null, an offline license is + * requested otherwise the offline license is renewed. + *
  • {@link #MODE_RELEASE}: {@code offlineLicenseKeySetId} can not be null. The offline license + * is released. + * + * @param mode The mode to be set. + * @param offlineLicenseKeySetId The key set id of the license to be used with the given mode. + */ + public void setMode(@Mode int mode, byte[] offlineLicenseKeySetId) { + Assertions.checkState(openCount == 0); + if (mode == MODE_QUERY || mode == MODE_RELEASE) { + Assertions.checkNotNull(offlineLicenseKeySetId); + } + this.mode = mode; + this.offlineLicenseKeySetId = offlineLicenseKeySetId; + } + // DrmSessionManager implementation. @Override @@ -248,18 +320,22 @@ public class StreamingDrmSessionManager implements Drm requestHandlerThread.start(); postRequestHandler = new PostRequestHandler(requestHandlerThread.getLooper()); - schemeData = drmInitData.get(uuid); - if (schemeData == null) { - onError(new IllegalStateException("Media does not support uuid: " + uuid)); - return this; - } - if (Util.SDK_INT < 21) { - // Prior to L the Widevine CDM required data to be extracted from the PSSH atom. - byte[] psshData = PsshAtomUtil.parseSchemeSpecificData(schemeData.data, C.WIDEVINE_UUID); - if (psshData == null) { - // Extraction failed. schemeData isn't a Widevine PSSH atom, so leave it unchanged. - } else { - schemeData = new SchemeData(C.WIDEVINE_UUID, schemeData.mimeType, psshData); + if (offlineLicenseKeySetId == null) { + SchemeData schemeData = drmInitData.get(uuid); + if (schemeData == null) { + onError(new IllegalStateException("Media does not support uuid: " + uuid)); + return this; + } + schemeInitData = schemeData.data; + schemeMimeType = schemeData.mimeType; + if (Util.SDK_INT < 21) { + // Prior to L the Widevine CDM required data to be extracted from the PSSH atom. + byte[] psshData = PsshAtomUtil.parseSchemeSpecificData(schemeInitData, C.WIDEVINE_UUID); + if (psshData == null) { + // Extraction failed. schemeData isn't a Widevine PSSH atom, so leave it unchanged. + } else { + schemeInitData = psshData; + } } } state = STATE_OPENING; @@ -280,7 +356,8 @@ public class StreamingDrmSessionManager implements Drm postRequestHandler = null; requestHandlerThread.quit(); requestHandlerThread = null; - schemeData = null; + schemeInitData = null; + schemeMimeType = null; mediaCrypto = null; lastException = null; if (sessionId != null) { @@ -314,10 +391,25 @@ public class StreamingDrmSessionManager implements Drm } @Override - public final Exception getError() { + public final DrmSessionException getError() { return state == STATE_ERROR ? lastException : null; } + @Override + public Map queryKeyStatus() { + // User may call this method rightfully even if state == STATE_ERROR. So only check if there is + // a sessionId + if (sessionId == null) { + throw new IllegalStateException(); + } + return mediaDrm.queryKeyStatus(sessionId); + } + + @Override + public byte[] getOfflineLicenseKeySetId() { + return offlineLicenseKeySetId; + } + // Internal methods. private void openInternal(boolean allowProvisioning) { @@ -325,7 +417,7 @@ public class StreamingDrmSessionManager implements Drm sessionId = mediaDrm.openSession(); mediaCrypto = mediaDrm.createMediaCrypto(uuid, sessionId); state = STATE_OPENED; - postKeyRequest(); + doLicense(); } catch (NotProvisionedException e) { if (allowProvisioning) { postProvisionRequest(); @@ -363,20 +455,87 @@ public class StreamingDrmSessionManager implements Drm if (state == STATE_OPENING) { openInternal(false); } else { - postKeyRequest(); + doLicense(); } } catch (DeniedByServerException e) { onError(e); } } - private void postKeyRequest() { + private void doLicense() { + switch (mode) { + case MODE_PLAYBACK: + case MODE_QUERY: + if (offlineLicenseKeySetId == null) { + postKeyRequest(sessionId, MediaDrm.KEY_TYPE_STREAMING); + } else { + if (restoreKeys()) { + long licenseDurationRemainingSec = getLicenseDurationRemainingSec(); + if (mode == MODE_PLAYBACK + && licenseDurationRemainingSec <= MAX_LICENSE_DURATION_TO_RENEW) { + Log.d(TAG, "Offline license has expired or will expire soon. " + + "Remaining seconds: " + licenseDurationRemainingSec); + postKeyRequest(sessionId, MediaDrm.KEY_TYPE_OFFLINE); + } else if (licenseDurationRemainingSec <= 0) { + onError(new KeysExpiredException()); + } else { + state = STATE_OPENED_WITH_KEYS; + if (eventHandler != null && eventListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + eventListener.onDrmKeysRestored(); + } + }); + } + } + } + } + break; + case MODE_DOWNLOAD: + if (offlineLicenseKeySetId == null) { + postKeyRequest(sessionId, MediaDrm.KEY_TYPE_OFFLINE); + } else { + // Renew + if (restoreKeys()) { + postKeyRequest(sessionId, MediaDrm.KEY_TYPE_OFFLINE); + } + } + break; + case MODE_RELEASE: + if (restoreKeys()) { + postKeyRequest(offlineLicenseKeySetId, MediaDrm.KEY_TYPE_RELEASE); + } + break; + } + } + + private boolean restoreKeys() { + try { + mediaDrm.restoreKeys(sessionId, offlineLicenseKeySetId); + return true; + } catch (Exception e) { + Log.e(TAG, "Error trying to restore Widevine keys.", e); + onError(e); + } + return false; + } + + private long getLicenseDurationRemainingSec() { + if (!C.WIDEVINE_UUID.equals(uuid)) { + return Long.MAX_VALUE; + } + Pair pair = WidevineUtil.getLicenseDurationRemainingSec(this); + return Math.min(pair.first, pair.second); + } + + private void postKeyRequest(byte[] scope, int keyType) { KeyRequest keyRequest; try { - keyRequest = mediaDrm.getKeyRequest(sessionId, schemeData.data, schemeData.mimeType, - MediaDrm.KEY_TYPE_STREAMING, optionalKeyRequestParameters); + keyRequest = mediaDrm.getKeyRequest(scope, schemeInitData, schemeMimeType, keyType, + optionalKeyRequestParameters); postRequestHandler.obtainMessage(MSG_KEYS, keyRequest).sendToTarget(); - } catch (NotProvisionedException e) { + } catch (Exception e) { onKeysError(e); } } @@ -393,15 +552,30 @@ public class StreamingDrmSessionManager implements Drm } try { - mediaDrm.provideKeyResponse(sessionId, (byte[]) response); - state = STATE_OPENED_WITH_KEYS; - if (eventHandler != null && eventListener != null) { - eventHandler.post(new Runnable() { - @Override - public void run() { - eventListener.onDrmKeysLoaded(); - } - }); + if (mode == MODE_RELEASE) { + mediaDrm.provideKeyResponse(offlineLicenseKeySetId, (byte[]) response); + if (eventHandler != null && eventListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + eventListener.onDrmKeysRemoved(); + } + }); + } + } else { + byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, (byte[]) response); + if (keySetId != null && keySetId.length != 0) { + offlineLicenseKeySetId = keySetId; + } + state = STATE_OPENED_WITH_KEYS; + if (eventHandler != null && eventListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + eventListener.onDrmKeysLoaded(); + } + }); + } } } catch (Exception e) { onKeysError(e); @@ -417,7 +591,7 @@ public class StreamingDrmSessionManager implements Drm } private void onError(final Exception e) { - lastException = e; + lastException = new DrmSessionException(e); if (eventHandler != null && eventListener != null) { eventHandler.post(new Runnable() { @Override @@ -446,11 +620,16 @@ public class StreamingDrmSessionManager implements Drm } switch (msg.what) { case MediaDrm.EVENT_KEY_REQUIRED: - postKeyRequest(); + doLicense(); break; case MediaDrm.EVENT_KEY_EXPIRED: - state = STATE_OPENED; - onError(new KeysExpiredException()); + // When an already expired key is loaded MediaDrm sends this event immediately. Ignore + // this event if the state isn't STATE_OPENED_WITH_KEYS yet which means we're still + // waiting for key response. + if (state == STATE_OPENED_WITH_KEYS) { + state = STATE_OPENED; + onError(new KeysExpiredException()); + } break; case MediaDrm.EVENT_PROVISION_REQUIRED: state = STATE_OPENED; @@ -466,7 +645,9 @@ public class StreamingDrmSessionManager implements Drm @Override public void onEvent(ExoMediaDrm md, byte[] sessionId, int event, int extra, byte[] data) { - mediaDrmHandler.sendEmptyMessage(event); + if (mode == MODE_PLAYBACK) { + mediaDrmHandler.sendEmptyMessage(event); + } } } diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java b/library/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java index 6f84395072..4d64187a8b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java +++ b/library/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java @@ -16,9 +16,11 @@ package com.google.android.exoplayer2.drm; import android.annotation.TargetApi; +import android.media.MediaDrm; import android.support.annotation.IntDef; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Map; /** * A DRM session. @@ -26,6 +28,15 @@ import java.lang.annotation.RetentionPolicy; @TargetApi(16) public interface DrmSession { + /** Wraps the exception which is the cause of the error state. */ + class DrmSessionException extends Exception { + + DrmSessionException(Exception e) { + super(e); + } + + } + /** * The state of the DRM session. */ @@ -96,6 +107,26 @@ public interface DrmSession { * * @return An exception if the state is {@link #STATE_ERROR}. Null otherwise. */ - Exception getError(); + DrmSessionException getError(); + + /** + * Returns an informative description of the key status for the session. The status is in the form + * of {name, value} pairs. + * + *

    Since DRM license policies vary by vendor, the specific status field names are determined by + * each DRM vendor. Refer to your DRM provider documentation for definitions of the field names + * for a particular DRM engine plugin. + * + * @return A map of key status. + * @throws IllegalStateException If called when the session isn't opened. + * @see MediaDrm#queryKeyStatus(byte[]) + */ + Map queryKeyStatus(); + + /** + * Returns the key set id of the offline license loaded into this session, if there is one. Null + * otherwise. + */ + byte[] getOfflineLicenseKeySetId(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java b/library/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java index 65e41dd91e..e0c9ca5296 100644 --- a/library/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java +++ b/library/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java @@ -105,7 +105,7 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { try { return Util.toByteArray(inputStream); } finally { - inputStream.close(); + Util.closeQuietly(inputStream); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java b/library/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java new file mode 100644 index 0000000000..a11d65d4d3 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java @@ -0,0 +1,315 @@ +/* + * 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.drm; + +import android.media.MediaDrm; +import android.net.Uri; +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Pair; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager.EventListener; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager.Mode; +import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.InitializationChunk; +import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; +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.RangedUri; +import com.google.android.exoplayer2.source.dash.manifest.Representation; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import java.io.IOException; +import java.util.HashMap; + +/** + * Helper class to download, renew and release offline licenses. It utilizes {@link + * DefaultDrmSessionManager}. + */ +public final class OfflineLicenseHelper { + + private final ConditionVariable conditionVariable; + private final DefaultDrmSessionManager drmSessionManager; + private final HandlerThread handlerThread; + + /** + * Helper method to download a DASH manifest. + * + * @param dataSource The {@link HttpDataSource} from which the manifest should be read. + * @param manifestUriString The URI of the manifest to be read. + * @return An instance of {@link DashManifest}. + * @throws IOException If an error occurs reading data from the stream. + * @see DashManifestParser + */ + public static DashManifest downloadManifest(HttpDataSource dataSource, String manifestUriString) + throws IOException { + DataSourceInputStream inputStream = new DataSourceInputStream( + dataSource, new DataSpec(Uri.parse(manifestUriString))); + try { + inputStream.open(); + DashManifestParser parser = new DashManifestParser(); + return parser.parse(dataSource.getUri(), inputStream); + } finally { + inputStream.close(); + } + } + + /** + * Instantiates a new instance which uses Widevine CDM. Call {@link #releaseResources()} when + * you're done with the helper instance. + * + * @param licenseUrl The default license URL. + * @param httpDataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. + * @return A new instance which uses Widevine CDM. + * @throws UnsupportedDrmException If the Widevine DRM scheme is unsupported or cannot be + * instantiated. + */ + public static OfflineLicenseHelper newWidevineInstance( + String licenseUrl, Factory httpDataSourceFactory) throws UnsupportedDrmException { + return newWidevineInstance( + new HttpMediaDrmCallback(licenseUrl, httpDataSourceFactory, null), null); + } + + /** + * Instantiates a new instance which uses Widevine CDM. Call {@link #releaseResources()} when + * you're done with the helper instance. + * + * @param callback Performs key and provisioning requests. + * @param optionalKeyRequestParameters An optional map of parameters to pass as the last argument + * to {@link MediaDrm#getKeyRequest(byte[], byte[], String, int, HashMap)}. May be null. + * @return A new instance which uses Widevine CDM. + * @throws UnsupportedDrmException If the Widevine DRM scheme is unsupported or cannot be + * instantiated. + * @see DefaultDrmSessionManager#DefaultDrmSessionManager(java.util.UUID, ExoMediaDrm, + * MediaDrmCallback, HashMap, Handler, EventListener) + */ + public static OfflineLicenseHelper newWidevineInstance( + MediaDrmCallback callback, HashMap optionalKeyRequestParameters) + throws UnsupportedDrmException { + return new OfflineLicenseHelper<>(FrameworkMediaDrm.newInstance(C.WIDEVINE_UUID), callback, + optionalKeyRequestParameters); + } + + /** + * Constructs an instance. Call {@link #releaseResources()} when you're done with it. + * + * @param mediaDrm An underlying {@link ExoMediaDrm} for use by the manager. + * @param callback Performs key and provisioning requests. + * @param optionalKeyRequestParameters An optional map of parameters to pass as the last argument + * to {@link MediaDrm#getKeyRequest(byte[], byte[], String, int, HashMap)}. May be null. + * @see DefaultDrmSessionManager#DefaultDrmSessionManager(java.util.UUID, ExoMediaDrm, + * MediaDrmCallback, HashMap, Handler, EventListener) + */ + public OfflineLicenseHelper(ExoMediaDrm mediaDrm, MediaDrmCallback callback, + HashMap optionalKeyRequestParameters) { + handlerThread = new HandlerThread("OfflineLicenseHelper"); + handlerThread.start(); + + conditionVariable = new ConditionVariable(); + EventListener eventListener = new EventListener() { + @Override + public void onDrmKeysLoaded() { + conditionVariable.open(); + } + + @Override + public void onDrmSessionManagerError(Exception e) { + conditionVariable.open(); + } + + @Override + public void onDrmKeysRestored() { + conditionVariable.open(); + } + + @Override + public void onDrmKeysRemoved() { + conditionVariable.open(); + } + }; + drmSessionManager = new DefaultDrmSessionManager<>(C.WIDEVINE_UUID, mediaDrm, callback, + optionalKeyRequestParameters, new Handler(handlerThread.getLooper()), eventListener); + } + + /** Releases the used resources. */ + public void releaseResources() { + handlerThread.quit(); + } + + /** + * Downloads an offline license. + * + * @param dataSource The {@link HttpDataSource} to be used for download. + * @param manifestUriString The URI of the manifest to be read. + * @return The downloaded offline license key set id. + * @throws IOException If an error occurs reading data from the stream. + * @throws InterruptedException If the thread has been interrupted. + * @throws DrmSessionException Thrown when there is an error during DRM session. + */ + public byte[] download(HttpDataSource dataSource, String manifestUriString) + throws IOException, InterruptedException, DrmSessionException { + return download(dataSource, downloadManifest(dataSource, manifestUriString)); + } + + /** + * Downloads an offline license. + * + * @param dataSource The {@link HttpDataSource} to be used for download. + * @param dashManifest The {@link DashManifest} of the DASH content. + * @return The downloaded offline license key set id. + * @throws IOException If an error occurs reading data from the stream. + * @throws InterruptedException If the thread has been interrupted. + * @throws DrmSessionException Thrown when there is an error during DRM session. + */ + public byte[] download(HttpDataSource dataSource, DashManifest dashManifest) + throws IOException, InterruptedException, DrmSessionException { + // Get DrmInitData + // Prefer drmInitData obtained from the manifest over drmInitData obtained from the stream, + // as per DASH IF Interoperability Recommendations V3.0, 7.5.3. + if (dashManifest.getPeriodCount() < 1) { + return null; + } + Period period = dashManifest.getPeriod(0); + int adaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_VIDEO); + if (adaptationSetIndex == C.INDEX_UNSET) { + adaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_AUDIO); + if (adaptationSetIndex == C.INDEX_UNSET) { + return null; + } + } + AdaptationSet adaptationSet = period.adaptationSets.get(adaptationSetIndex); + if (adaptationSet.representations.isEmpty()) { + return null; + } + Representation representation = adaptationSet.representations.get(0); + DrmInitData drmInitData = representation.format.drmInitData; + if (drmInitData == null) { + InitializationChunk initializationChunk = loadInitializationChunk(dataSource, representation); + if (initializationChunk == null) { + return null; + } + Format sampleFormat = initializationChunk.getSampleFormat(); + if (sampleFormat != null) { + drmInitData = sampleFormat.drmInitData; + } + if (drmInitData == null) { + return null; + } + } + blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, drmInitData); + return drmSessionManager.getOfflineLicenseKeySetId(); + } + + /** + * Renews an offline license. + * + * @param offlineLicenseKeySetId The key set id of the license to be renewed. + * @return Renewed offline license key set id. + * @throws DrmSessionException Thrown when there is an error during DRM session. + */ + public byte[] renew(byte[] offlineLicenseKeySetId) throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, offlineLicenseKeySetId, null); + return drmSessionManager.getOfflineLicenseKeySetId(); + } + + /** + * Releases an offline license. + * + * @param offlineLicenseKeySetId The key set id of the license to be released. + * @throws DrmSessionException Thrown when there is an error during DRM session. + */ + public void release(byte[] offlineLicenseKeySetId) throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + blockingKeyRequest(DefaultDrmSessionManager.MODE_RELEASE, offlineLicenseKeySetId, null); + } + + /** + * Returns license and playback durations remaining in seconds of the given offline license. + * + * @param offlineLicenseKeySetId The key set id of the license. + */ + public Pair getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId) + throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + DrmSession session = openBlockingKeyRequest(DefaultDrmSessionManager.MODE_QUERY, + offlineLicenseKeySetId, null); + Pair licenseDurationRemainingSec = + WidevineUtil.getLicenseDurationRemainingSec(drmSessionManager); + drmSessionManager.releaseSession(session); + return licenseDurationRemainingSec; + } + + private void blockingKeyRequest(@Mode int licenseMode, byte[] offlineLicenseKeySetId, + DrmInitData drmInitData) throws DrmSessionException { + DrmSession session = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, + drmInitData); + DrmSessionException error = session.getError(); + if (error != null) { + throw error; + } + drmSessionManager.releaseSession(session); + } + + private DrmSession openBlockingKeyRequest(@Mode int licenseMode, byte[] offlineLicenseKeySetId, + DrmInitData drmInitData) { + drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); + conditionVariable.close(); + DrmSession session = drmSessionManager.acquireSession(handlerThread.getLooper(), + drmInitData); + // Block current thread until key loading is finished + conditionVariable.block(); + return session; + } + + private static InitializationChunk loadInitializationChunk(final DataSource dataSource, + final Representation representation) throws IOException, InterruptedException { + RangedUri rangedUri = representation.getInitializationUri(); + if (rangedUri == null) { + return null; + } + DataSpec dataSpec = new DataSpec(rangedUri.resolveUri(representation.baseUrl), rangedUri.start, + rangedUri.length, representation.getCacheKey()); + InitializationChunk initializationChunk = new InitializationChunk(dataSource, dataSpec, + representation.format, C.SELECTION_REASON_UNKNOWN, null /* trackSelectionData */, + newWrappedExtractor(representation.format)); + initializationChunk.load(); + return initializationChunk; + } + + private static ChunkExtractorWrapper newWrappedExtractor(final Format format) { + final String mimeType = format.containerMimeType; + final boolean isWebm = mimeType.startsWith(MimeTypes.VIDEO_WEBM) + || mimeType.startsWith(MimeTypes.AUDIO_WEBM); + final Extractor extractor = isWebm ? new MatroskaExtractor() : new FragmentedMp4Extractor(); + return new ChunkExtractorWrapper(extractor, format, false /* preferManifestDrmInitData */, + false /* resendFormatOnInit */); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/drm/WidevineUtil.java b/library/src/main/java/com/google/android/exoplayer2/drm/WidevineUtil.java new file mode 100644 index 0000000000..fc80cfb6fb --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/drm/WidevineUtil.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2017 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.drm; + +import android.util.Pair; +import com.google.android.exoplayer2.C; +import java.util.Map; + +/** + * Utility methods for Widevine. + */ +public final class WidevineUtil { + + /** Widevine specific key status field name for the remaining license duration, in seconds. */ + public static final String PROPERTY_LICENSE_DURATION_REMAINING = "LicenseDurationRemaining"; + /** Widevine specific key status field name for the remaining playback duration, in seconds. */ + public static final String PROPERTY_PLAYBACK_DURATION_REMAINING = "PlaybackDurationRemaining"; + + private WidevineUtil() {} + + /** + * Returns license and playback durations remaining in seconds. + * + * @return A {@link Pair} consisting of the remaining license and playback durations in seconds. + * @throws IllegalStateException If called when a session isn't opened. + * @param drmSession + */ + public static Pair getLicenseDurationRemainingSec(DrmSession drmSession) { + Map keyStatus = drmSession.queryKeyStatus(); + return new Pair<>( + getDurationRemainingSec(keyStatus, PROPERTY_LICENSE_DURATION_REMAINING), + getDurationRemainingSec(keyStatus, PROPERTY_PLAYBACK_DURATION_REMAINING)); + } + + private static long getDurationRemainingSec(Map keyStatus, String property) { + if (keyStatus != null) { + try { + String value = keyStatus.get(property); + if (value != null) { + return Long.parseLong(value); + } + } catch (NumberFormatException e) { + // do nothing. + } + } + return C.TIME_UNSET; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/DefaultTrackOutput.java b/library/src/main/java/com/google/android/exoplayer2/extractor/DefaultTrackOutput.java index 44756a507e..b3bcd97048 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/DefaultTrackOutput.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/DefaultTrackOutput.java @@ -226,13 +226,32 @@ public final class DefaultTrackOutput implements TrackOutput { } /** - * Attempts to skip to the keyframe before the specified time, if it's present in the buffer. + * Attempts to skip to the keyframe before or at the specified time. Succeeds only if the buffer + * contains a keyframe with a timestamp of {@code timeUs} or earlier, and if {@code timeUs} falls + * within the currently buffered media. + *

    + * This method is equivalent to {@code skipToKeyframeBefore(timeUs, false)}. * * @param timeUs The seek time. * @return Whether the skip was successful. */ public boolean skipToKeyframeBefore(long timeUs) { - long nextOffset = infoQueue.skipToKeyframeBefore(timeUs); + return skipToKeyframeBefore(timeUs, false); + } + + /** + * Attempts to skip to the keyframe before or at the specified time. Succeeds only if the buffer + * contains a keyframe with a timestamp of {@code timeUs} or earlier. If + * {@code allowTimeBeyondBuffer} is {@code false} then it is also required that {@code timeUs} + * falls within the buffer. + * + * @param timeUs The seek time. + * @param allowTimeBeyondBuffer Whether the skip can succeed if {@code timeUs} is beyond the end + * of the buffer. + * @return Whether the skip was successful. + */ + public boolean skipToKeyframeBefore(long timeUs, boolean allowTimeBeyondBuffer) { + long nextOffset = infoQueue.skipToKeyframeBefore(timeUs, allowTimeBeyondBuffer); if (nextOffset == C.POSITION_UNSET) { return false; } @@ -246,7 +265,8 @@ public final class DefaultTrackOutput implements TrackOutput { * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the * end of the stream. If the end of the stream has been reached, the - * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. + * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. May be null if the + * caller requires that the format of the stream be read even if it's not changing. * @param loadingFinished True if an empty queue should be considered the end of the stream. * @param decodeOnlyUntilUs If a buffer is read, the {@link C#BUFFER_FLAG_DECODE_ONLY} flag will * be set if the buffer's timestamp is less than this value. @@ -732,7 +752,8 @@ public final class DefaultTrackOutput implements TrackOutput { * about the sample, but not its data. The size and absolute position of the data in the * rolling buffer is stored in {@code extrasHolder}, along with an encryption id if present * and the absolute position of the first byte that may still be required after the current - * sample has been read. + * sample has been read. May be null if the caller requires that the format of the stream be + * read even if it's not changing. * @param downstreamFormat The current downstream {@link Format}. If the format of the next * sample is different to the current downstream format then a format will be read. * @param extrasHolder The holder into which extra sample information should be written. @@ -742,14 +763,14 @@ public final class DefaultTrackOutput implements TrackOutput { public synchronized int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, Format downstreamFormat, BufferExtrasHolder extrasHolder) { if (queueSize == 0) { - if (upstreamFormat != null && upstreamFormat != downstreamFormat) { + if (upstreamFormat != null && (buffer == null || upstreamFormat != downstreamFormat)) { formatHolder.format = upstreamFormat; return C.RESULT_FORMAT_READ; } return C.RESULT_NOTHING_READ; } - if (formats[relativeReadIndex] != downstreamFormat) { + if (buffer == null || formats[relativeReadIndex] != downstreamFormat) { formatHolder.format = formats[relativeReadIndex]; return C.RESULT_FORMAT_READ; } @@ -775,18 +796,22 @@ public final class DefaultTrackOutput implements TrackOutput { } /** - * Attempts to locate the keyframe before the specified time, if it's present in the buffer. + * Attempts to locate the keyframe before or at the specified time. If + * {@code allowTimeBeyondBuffer} is {@code false} then it is also required that {@code timeUs} + * falls within the buffer. * * @param timeUs The seek time. + * @param allowTimeBeyondBuffer Whether the skip can succeed if {@code timeUs} is beyond the end + * of the buffer. * @return The offset of the keyframe's data if the keyframe was present. * {@link C#POSITION_UNSET} otherwise. */ - public synchronized long skipToKeyframeBefore(long timeUs) { + public synchronized long skipToKeyframeBefore(long timeUs, boolean allowTimeBeyondBuffer) { if (queueSize == 0 || timeUs < timesUs[relativeReadIndex]) { return C.POSITION_UNSET; } - if (timeUs > largestQueuedTimestampUs) { + if (timeUs > largestQueuedTimestampUs && !allowTimeBeyondBuffer) { return C.POSITION_UNSET; } diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/TimestampAdjuster.java b/library/src/main/java/com/google/android/exoplayer2/extractor/TimestampAdjuster.java index 4de4b38897..a4da5d8e66 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/TimestampAdjuster.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/TimestampAdjuster.java @@ -51,6 +51,34 @@ public final class TimestampAdjuster { lastSampleTimestamp = C.TIME_UNSET; } + /** + * Returns the last adjusted timestamp. If no timestamp has been adjusted, returns + * {@code firstSampleTimestampUs} as provided to the constructor. If this value is + * {@link #DO_NOT_OFFSET}, returns {@link C#TIME_UNSET}. + * + * @return The last adjusted timestamp. If not present, {@code firstSampleTimestampUs} is + * returned unless equal to {@link #DO_NOT_OFFSET}, in which case {@link C#TIME_UNSET} is + * returned. + */ + public long getLastAdjustedTimestampUs() { + return lastSampleTimestamp != C.TIME_UNSET ? lastSampleTimestamp + : firstSampleTimestampUs != DO_NOT_OFFSET ? firstSampleTimestampUs : C.TIME_UNSET; + } + + /** + * Returns the offset between the input of {@link #adjustSampleTimestamp(long)} and its output. + * If {@link #DO_NOT_OFFSET} was provided to the constructor, 0 is returned. If the timestamp + * adjuster is yet not initialized, {@link C#TIME_UNSET} is returned. + * + * @return The offset between {@link #adjustSampleTimestamp(long)}'s input and output. + * {@link C#TIME_UNSET} if the adjuster is not yet initialized and 0 if timestamps should not + * be offset. + */ + public long getTimestampOffsetUs() { + return firstSampleTimestampUs == DO_NOT_OFFSET ? 0 + : lastSampleTimestamp == C.TIME_UNSET ? C.TIME_UNSET : timestampOffsetUs; + } + /** * Resets the instance to its initial state. */ 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 2eac7926e7..c8ee8ff8c3 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 @@ -127,6 +127,7 @@ import java.util.List; public static final int TYPE_mean = Util.getIntegerCodeForString("mean"); public static final int TYPE_name = Util.getIntegerCodeForString("name"); public static final int TYPE_data = Util.getIntegerCodeForString("data"); + public static final int TYPE_emsg = Util.getIntegerCodeForString("emsg"); public static final int TYPE_st3d = Util.getIntegerCodeForString("st3d"); public static final int TYPE_sv3d = Util.getIntegerCodeForString("sv3d"); public static final int TYPE_proj = Util.getIntegerCodeForString("proj"); 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 75d7cc555c..603aec4b22 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 @@ -20,6 +20,7 @@ import android.util.Log; import android.util.Pair; import android.util.SparseArray; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; @@ -44,6 +45,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedList; import java.util.List; import java.util.Stack; import java.util.UUID; @@ -73,7 +75,7 @@ public final class FragmentedMp4Extractor implements Extractor { */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = {FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME, - FLAG_WORKAROUND_IGNORE_TFDT_BOX, FLAG_SIDELOADED}) + FLAG_WORKAROUND_IGNORE_TFDT_BOX, FLAG_ENABLE_EMSG_TRACK, FLAG_SIDELOADED}) public @interface Flags {} /** * Flag to work around an issue in some video streams where every frame is marked as a sync frame. @@ -87,11 +89,16 @@ public final class FragmentedMp4Extractor implements Extractor { * Flag to ignore any tfdt boxes in the stream. */ public static final int FLAG_WORKAROUND_IGNORE_TFDT_BOX = 2; + /** + * Flag to indicate that the extractor should output an event message metadata track. Any event + * messages in the stream will be delivered as samples to this track. + */ + public static final int FLAG_ENABLE_EMSG_TRACK = 4; /** * Flag to indicate that the {@link Track} was sideloaded, instead of being declared by the MP4 * container. */ - private static final int FLAG_SIDELOADED = 4; + private static final int FLAG_SIDELOADED = 8; private static final byte[] PIFF_SAMPLE_ENCRYPTION_BOX_EXTENDED_TYPE = new byte[] {-94, 57, 79, 82, 90, -101, 79, 20, -94, 68, 108, 66, 124, 100, -115, -12}; @@ -123,6 +130,7 @@ public final class FragmentedMp4Extractor implements Extractor { private final ParsableByteArray atomHeader; private final byte[] extendedTypeScratch; private final Stack containerAtoms; + private final LinkedList pendingMetadataSampleInfos; private int parserState; private int atomType; @@ -130,8 +138,10 @@ public final class FragmentedMp4Extractor implements Extractor { private int atomHeaderBytesRead; private ParsableByteArray atomData; private long endOfMdatPosition; + private int pendingMetadataSampleBytes; private long durationUs; + private long segmentIndexEarliestPresentationTimeUs; private TrackBundle currentTrackBundle; private int sampleSize; private int sampleBytesWritten; @@ -139,6 +149,7 @@ public final class FragmentedMp4Extractor implements Extractor { // Extractor output. private ExtractorOutput extractorOutput; + private TrackOutput eventMessageTrackOutput; // Whether extractorOutput.seekMap has been called. private boolean haveOutputSeekMap; @@ -172,8 +183,10 @@ public final class FragmentedMp4Extractor implements Extractor { encryptionSignalByte = new ParsableByteArray(1); extendedTypeScratch = new byte[16]; containerAtoms = new Stack<>(); + pendingMetadataSampleInfos = new LinkedList<>(); trackBundles = new SparseArray<>(); durationUs = C.TIME_UNSET; + segmentIndexEarliestPresentationTimeUs = C.TIME_UNSET; enterReadingAtomHeaderState(); } @@ -189,6 +202,7 @@ public final class FragmentedMp4Extractor implements Extractor { TrackBundle bundle = new TrackBundle(output.track(0)); bundle.init(sideloadedTrack, new DefaultSampleValues(0, 0, 0, 0)); trackBundles.put(0, bundle); + maybeInitEventMessageTrack(); extractorOutput.endTracks(); } } @@ -199,6 +213,8 @@ public final class FragmentedMp4Extractor implements Extractor { for (int i = 0; i < trackCount; i++) { trackBundles.valueAt(i).reset(); } + pendingMetadataSampleInfos.clear(); + pendingMetadataSampleBytes = 0; containerAtoms.clear(); enterReadingAtomHeaderState(); } @@ -336,9 +352,12 @@ public final class FragmentedMp4Extractor implements Extractor { if (!containerAtoms.isEmpty()) { containerAtoms.peek().add(leaf); } else if (leaf.type == Atom.TYPE_sidx) { - ChunkIndex segmentIndex = parseSidx(leaf.data, inputPosition); - extractorOutput.seekMap(segmentIndex); + Pair result = parseSidx(leaf.data, inputPosition); + segmentIndexEarliestPresentationTimeUs = result.first; + extractorOutput.seekMap(result.second); haveOutputSeekMap = true; + } else if (leaf.type == Atom.TYPE_emsg) { + onEmsgLeafAtomRead(leaf.data); } } @@ -394,6 +413,7 @@ public final class FragmentedMp4Extractor implements Extractor { trackBundles.put(track.id, new TrackBundle(extractorOutput.track(i))); durationUs = Math.max(durationUs, track.durationUs); } + maybeInitEventMessageTrack(); extractorOutput.endTracks(); } else { Assertions.checkState(trackBundles.size() == trackCount); @@ -417,6 +437,47 @@ public final class FragmentedMp4Extractor implements Extractor { } } + private void maybeInitEventMessageTrack() { + if ((flags & FLAG_ENABLE_EMSG_TRACK) == 0) { + return; + } + eventMessageTrackOutput = extractorOutput.track(trackBundles.size()); + eventMessageTrackOutput.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_EMSG, + Format.OFFSET_SAMPLE_RELATIVE)); + } + + /** + * Handles an emsg atom (defined in 23009-1). + */ + private void onEmsgLeafAtomRead(ParsableByteArray atom) { + if (eventMessageTrackOutput == null) { + return; + } + // Parse the event's presentation time delta. + atom.setPosition(Atom.FULL_HEADER_SIZE); + atom.readNullTerminatedString(); // schemeIdUri + atom.readNullTerminatedString(); // value + long timescale = atom.readUnsignedInt(); + long presentationTimeDeltaUs = + Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MICROS_PER_SECOND, timescale); + // Output the sample data. + atom.setPosition(Atom.FULL_HEADER_SIZE); + int sampleSize = atom.bytesLeft(); + eventMessageTrackOutput.sampleData(atom, sampleSize); + // Output the sample metadata. + if (segmentIndexEarliestPresentationTimeUs != C.TIME_UNSET) { + // We can output the sample metadata immediately. + eventMessageTrackOutput.sampleMetadata( + segmentIndexEarliestPresentationTimeUs + presentationTimeDeltaUs, + C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0 /* offset */, null); + } else { + // We need the first sample timestamp in the segment before we can output the metadata. + pendingMetadataSampleInfos.addLast( + new MetadataSampleInfo(presentationTimeDeltaUs, sampleSize)); + pendingMetadataSampleBytes += sampleSize; + } + } + /** * Parses a trex atom (defined in 14496-12). */ @@ -628,7 +689,7 @@ public final class FragmentedMp4Extractor implements Extractor { DefaultSampleValues defaultSampleValues = trackBundle.defaultSampleValues; int defaultSampleDescriptionIndex = ((atomFlags & 0x02 /* default_sample_description_index_present */) != 0) - ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; + ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; int defaultSampleDuration = ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) ? tfhd.readUnsignedIntToInt() : defaultSampleValues.duration; int defaultSampleSize = ((atomFlags & 0x10 /* default_sample_size_present */) != 0) @@ -832,8 +893,13 @@ public final class FragmentedMp4Extractor implements Extractor { /** * Parses a sidx atom (defined in 14496-12). + * + * @param atom The atom data. + * @param inputPosition The input position of the first byte after the atom. + * @return A pair consisting of the earliest presentation time in microseconds, and the parsed + * {@link ChunkIndex}. */ - private static ChunkIndex parseSidx(ParsableByteArray atom, long inputPosition) + private static Pair parseSidx(ParsableByteArray atom, long inputPosition) throws ParserException { atom.setPosition(Atom.HEADER_SIZE); int fullAtom = atom.readInt(); @@ -850,6 +916,8 @@ public final class FragmentedMp4Extractor implements Extractor { earliestPresentationTime = atom.readUnsignedLongToLong(); offset += atom.readUnsignedLongToLong(); } + long earliestPresentationTimeUs = Util.scaleLargeTimestamp(earliestPresentationTime, + C.MICROS_PER_SECOND, timescale); atom.skipBytes(2); @@ -860,7 +928,7 @@ public final class FragmentedMp4Extractor implements Extractor { long[] timesUs = new long[referenceCount]; long time = earliestPresentationTime; - long timeUs = Util.scaleLargeTimestamp(time, C.MICROS_PER_SECOND, timescale); + long timeUs = earliestPresentationTimeUs; for (int i = 0; i < referenceCount; i++) { int firstInt = atom.readInt(); @@ -884,7 +952,8 @@ public final class FragmentedMp4Extractor implements Extractor { offset += sizes[i]; } - return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + return Pair.create(earliestPresentationTimeUs, + new ChunkIndex(sizes, offsets, durationsUs, timesUs)); } private void readEncryptionData(ExtractorInput input) throws IOException, InterruptedException { @@ -946,13 +1015,9 @@ public final class FragmentedMp4Extractor implements Extractor { // We skip bytes preceding the next sample to read. int bytesToSkip = (int) (nextDataPosition - input.getPosition()); if (bytesToSkip < 0) { - if (nextDataPosition == currentTrackBundle.fragment.atomPosition) { - // Assume the sample data must be contiguous in the mdat with no preceeding data. - Log.w(TAG, "Offset to sample data was missing."); - bytesToSkip = 0; - } else { - throw new ParserException("Offset to sample data was negative."); - } + // Assume the sample data must be contiguous in the mdat with no preceding data. + Log.w(TAG, "Ignoring negative offset to sample data."); + bytesToSkip = 0; } input.skipFully(bytesToSkip); this.currentTrackBundle = currentTrackBundle; @@ -1029,6 +1094,14 @@ public final class FragmentedMp4Extractor implements Extractor { } output.sampleMetadata(sampleTimeUs, sampleFlags, sampleSize, 0, encryptionKey); + while (!pendingMetadataSampleInfos.isEmpty()) { + MetadataSampleInfo sampleInfo = pendingMetadataSampleInfos.removeFirst(); + pendingMetadataSampleBytes -= sampleInfo.size; + eventMessageTrackOutput.sampleMetadata( + sampleTimeUs + sampleInfo.presentationTimeDeltaUs, + C.BUFFER_FLAG_KEY_FRAME, sampleInfo.size, pendingMetadataSampleBytes, null); + } + currentTrackBundle.currentSampleIndex++; currentTrackBundle.currentSampleInTrackRun++; if (currentTrackBundle.currentSampleInTrackRun @@ -1134,7 +1207,7 @@ public final class FragmentedMp4Extractor implements Extractor { || atom == Atom.TYPE_trun || atom == Atom.TYPE_pssh || atom == Atom.TYPE_saiz || atom == Atom.TYPE_saio || atom == Atom.TYPE_senc || atom == Atom.TYPE_uuid || atom == Atom.TYPE_sbgp || atom == Atom.TYPE_sgpd || atom == Atom.TYPE_elst - || atom == Atom.TYPE_mehd; + || atom == Atom.TYPE_mehd || atom == Atom.TYPE_emsg; } /** Returns whether the extractor should decode a container atom with type {@code atom}. */ @@ -1144,6 +1217,21 @@ public final class FragmentedMp4Extractor implements Extractor { || atom == Atom.TYPE_traf || atom == Atom.TYPE_mvex || atom == Atom.TYPE_edts; } + /** + * Holds data corresponding to a metadata sample. + */ + private static final class MetadataSampleInfo { + + public final long presentationTimeDeltaUs; + public final int size; + + public MetadataSampleInfo(long presentationTimeDeltaUs, int size) { + this.presentationTimeDeltaUs = presentationTimeDeltaUs; + this.size = size; + } + + } + /** * Holds data corresponding to a single track. */ 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 index e99dab053b..fed1694925 100644 --- 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 @@ -188,7 +188,7 @@ import com.google.android.exoplayer2.util.Util; 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); + return new TextInformationFrame(id, null, value); } Log.w(TAG, "Failed to parse text attribute: " + Atom.getAtomTypeString(type)); return null; @@ -213,7 +213,7 @@ import com.google.android.exoplayer2.util.Util; value = Math.min(1, value); } if (value >= 0) { - return isTextInformationFrame ? new TextInformationFrame(id, Integer.toString(value)) + return isTextInformationFrame ? new TextInformationFrame(id, null, Integer.toString(value)) : new CommentFrame(LANGUAGE_UNDEFINED, id, Integer.toString(value)); } Log.w(TAG, "Failed to parse uint8 attribute: " + Atom.getAtomTypeString(type)); @@ -228,12 +228,12 @@ import com.google.android.exoplayer2.util.Util; data.skipBytes(10); // version (1), flags (3), empty (4), empty (2) int index = data.readUnsignedShort(); if (index > 0) { - String description = "" + index; + String value = "" + index; int count = data.readUnsignedShort(); if (count > 0) { - description += "/" + count; + value += "/" + count; } - return new TextInformationFrame(attributeName, description); + return new TextInformationFrame(attributeName, null, value); } } Log.w(TAG, "Failed to parse index/count attribute: " + Atom.getAtomTypeString(type)); @@ -245,7 +245,7 @@ import com.google.android.exoplayer2.util.Util; String genreString = (0 < genreCode && genreCode <= STANDARD_GENRES.length) ? STANDARD_GENRES[genreCode - 1] : null; if (genreString != null) { - return new TextInformationFrame("TCON", genreString); + return new TextInformationFrame("TCON", null, genreString); } Log.w(TAG, "Failed to parse standard genre code"); return null; 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 f6cd29aff2..f9957aebe5 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 @@ -83,8 +83,11 @@ public final class RawCcExtractor implements Extractor { while (true) { switch (parserState) { case STATE_READING_HEADER: - parseHeader(input); - parserState = STATE_READING_TIMESTAMP_AND_COUNT; + if (parseHeader(input)) { + parserState = STATE_READING_TIMESTAMP_AND_COUNT; + } else { + return RESULT_END_OF_INPUT; + } break; case STATE_READING_TIMESTAMP_AND_COUNT: if (parseTimestampAndSampleCount(input)) { @@ -114,14 +117,18 @@ public final class RawCcExtractor implements Extractor { // Do nothing } - private void parseHeader(ExtractorInput input) throws IOException, InterruptedException { + private boolean parseHeader(ExtractorInput input) throws IOException, InterruptedException { dataScratch.reset(); - input.readFully(dataScratch.data, 0, HEADER_SIZE); - if (dataScratch.readInt() != HEADER_ID) { - throw new IOException("Input not RawCC"); + if (input.readFully(dataScratch.data, 0, HEADER_SIZE, true)) { + if (dataScratch.readInt() != HEADER_ID) { + throw new IOException("Input not RawCC"); + } + version = dataScratch.readUnsignedByte(); + // no versions use the flag fields yet + return true; + } else { + return false; } - version = dataScratch.readUnsignedByte(); - // no versions use the flag fields yet } private boolean parseTimestampAndSampleCount(ExtractorInput input) throws IOException, 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 index b1e71d6651..121a622362 100644 --- 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 @@ -28,11 +28,14 @@ import com.google.android.exoplayer2.util.ParsableByteArray; */ public final class SpliceInfoSectionReader implements SectionPayloadReader { + private TimestampAdjuster timestampAdjuster; private TrackOutput output; + private boolean formatDeclared; @Override public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator) { + this.timestampAdjuster = timestampAdjuster; output = extractorOutput.track(idGenerator.getNextId()); output.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_SCTE35, null, Format.NO_VALUE, null)); @@ -40,9 +43,19 @@ public final class SpliceInfoSectionReader implements SectionPayloadReader { @Override public void consume(ParsableByteArray sectionData) { + if (!formatDeclared) { + if (timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET) { + // There is not enough information to initialize the timestamp adjuster. + return; + } + output.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_SCTE35, + timestampAdjuster.getTimestampOffsetUs())); + formatDeclared = true; + } int sampleSize = sectionData.bytesLeft(); output.sampleData(sectionData, sampleSize); - output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + output.sampleMetadata(timestampAdjuster.getLastAdjustedTimestampUs(), C.BUFFER_FLAG_KEY_FRAME, + sampleSize, 0, null); } } 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 6dce2abc2a..7e8b83b84c 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, false); + return mediaCodecSelector.getDecoderInfo(format.sampleMimeType, requiresSecureDecoder); } /** 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 ea8832c39c..bb946d76f9 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, - boolean requiresTunneling) throws DecoderQueryException { - return MediaCodecUtil.getDecoderInfo(mimeType, requiresSecureDecoder, requiresTunneling); + public MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder) + throws DecoderQueryException { + return MediaCodecUtil.getDecoderInfo(mimeType, requiresSecureDecoder); } @Override @@ -46,13 +46,11 @@ 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. + * @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, - boolean requiresTunneling) throws DecoderQueryException; + MediaCodecInfo getDecoderInfo(String mimeType, boolean requiresSecureDecoder) + 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 14ba309790..a3a2543461 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 @@ -81,9 +81,8 @@ 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, boolean)} and - * {@link #getDecoderInfos(String, boolean)}. + * Calling this method may speed up subsequent calls to {@link #getDecoderInfo(String, 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 @@ -115,26 +114,14 @@ 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, boolean tunneling) + public static MediaCodecInfo getDecoderInfo(String mimeType, boolean secure) throws DecoderQueryException { List decoderInfos = getDecoderInfos(mimeType, secure); - 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); - } + return decoderInfos.isEmpty() ? null : decoderInfos.get(0); } /** @@ -305,7 +292,7 @@ public final class MediaCodecUtil { public static int maxH264DecodableFrameSize() throws DecoderQueryException { if (maxH264DecodableFrameSize == -1) { int result = 0; - MediaCodecInfo decoderInfo = getDecoderInfo(MimeTypes.VIDEO_H264, false, false); + MediaCodecInfo decoderInfo = getDecoderInfo(MimeTypes.VIDEO_H264, false); if (decoderInfo != null) { for (CodecProfileLevel profileLevel : decoderInfo.getProfileLevels()) { result = Math.max(avcLevelToMaxFrameSize(profileLevel.level), result); 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 a73311f16b..9137bad4fd 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 @@ -21,21 +21,12 @@ package com.google.android.exoplayer2.metadata; public interface MetadataDecoder { /** - * Checks whether the decoder supports a given mime type. + * Decodes a {@link Metadata} element from the provided input buffer. * - * @param mimeType A metadata mime type. - * @return Whether the mime type is supported. - */ - boolean canDecode(String mimeType); - - /** - * Decodes a metadata object from the provided binary data. - * - * @param data The raw binary data from which to decode the metadata. - * @param size The size of the input data. + * @param inputBuffer The input buffer to decode. * @return The decoded metadata object. * @throws MetadataDecoderException If a problem occurred decoding the data. */ - Metadata decode(byte[] data, int size) throws MetadataDecoderException; + Metadata decode(MetadataInputBuffer inputBuffer) throws MetadataDecoderException; } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java new file mode 100644 index 0000000000..414a8269d7 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2017 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 com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.metadata.scte35.SpliceInfoDecoder; +import com.google.android.exoplayer2.util.MimeTypes; + +/** + * A factory for {@link MetadataDecoder} instances. + */ +public interface MetadataDecoderFactory { + + /** + * Returns whether the factory is able to instantiate a {@link MetadataDecoder} for the given + * {@link Format}. + * + * @param format The {@link Format}. + * @return Whether the factory can instantiate a suitable {@link MetadataDecoder}. + */ + boolean supportsFormat(Format format); + + /** + * Creates a {@link MetadataDecoder} for the given {@link Format}. + * + * @param format The {@link Format}. + * @return A new {@link MetadataDecoder}. + * @throws IllegalArgumentException If the {@link Format} is not supported. + */ + MetadataDecoder createDecoder(Format format); + + /** + * Default {@link MetadataDecoder} implementation. + *

    + * The formats supported by this factory are: + *

      + *
    • ID3 ({@link Id3Decoder})
    • + *
    • EMSG ({@link EventMessageDecoder})
    • + *
    • SCTE-35 ({@link SpliceInfoDecoder})
    • + *
    + */ + MetadataDecoderFactory DEFAULT = new MetadataDecoderFactory() { + + @Override + public boolean supportsFormat(Format format) { + return getDecoderClass(format.sampleMimeType) != null; + } + + @Override + public MetadataDecoder createDecoder(Format format) { + try { + Class clazz = getDecoderClass(format.sampleMimeType); + if (clazz == null) { + throw new IllegalArgumentException("Attempted to create decoder for unsupported format"); + } + return clazz.asSubclass(MetadataDecoder.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.APPLICATION_ID3: + return Class.forName("com.google.android.exoplayer2.metadata.id3.Id3Decoder"); + case MimeTypes.APPLICATION_EMSG: + return Class.forName("com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder"); + case MimeTypes.APPLICATION_SCTE35: + return Class.forName("com.google.android.exoplayer2.metadata.scte35.SpliceInfoDecoder"); + default: + return null; + } + } catch (ClassNotFoundException e) { + return null; + } + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java new file mode 100644 index 0000000000..a09b565653 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java @@ -0,0 +1,36 @@ +/* + * 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 com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +/** + * A {@link DecoderInputBuffer} for a {@link MetadataDecoder}. + */ +public final class MetadataInputBuffer extends DecoderInputBuffer { + + /** + * An offset that must be added to the metadata's timestamps after it's been decoded, or + * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@link #timeUs} should be added. + */ + public long subsampleOffsetUs; + + public MetadataInputBuffer() { + super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + } + +} 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 ff1364610b..550a13771f 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 @@ -24,9 +24,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; -import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.util.Assertions; -import java.nio.ByteBuffer; /** * A renderer for metadata. @@ -49,12 +47,13 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { private static final int MSG_INVOKE_RENDERER = 0; - private final MetadataDecoder metadataDecoder; + private final MetadataDecoderFactory decoderFactory; private final Output output; private final Handler outputHandler; private final FormatHolder formatHolder; - private final DecoderInputBuffer buffer; + private final MetadataInputBuffer buffer; + private MetadataDecoder decoder; private boolean inputStreamEnded; private long pendingMetadataTimestamp; private Metadata pendingMetadata; @@ -66,21 +65,38 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { * looper associated with the application's main thread, which can be obtained using * {@link android.app.Activity#getMainLooper()}. Null may be passed if the output should be * 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) { + this(output, outputLooper, MetadataDecoderFactory.DEFAULT); + } + + /** + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using + * {@link android.app.Activity#getMainLooper()}. Null may be passed if the output should be + * called directly on the player's internal rendering thread. + * @param decoderFactory A factory from which to obtain {@link MetadataDecoder} instances. + */ + public MetadataRenderer(Output output, Looper outputLooper, + MetadataDecoderFactory decoderFactory) { super(C.TRACK_TYPE_METADATA); this.output = Assertions.checkNotNull(output); this.outputHandler = outputLooper == null ? null : new Handler(outputLooper, this); - this.metadataDecoder = Assertions.checkNotNull(metadataDecoder); + this.decoderFactory = Assertions.checkNotNull(decoderFactory); formatHolder = new FormatHolder(); - buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + buffer = new MetadataInputBuffer(); } @Override public int supportsFormat(Format format) { - return metadataDecoder.canDecode(format.sampleMimeType) ? FORMAT_HANDLED - : FORMAT_UNSUPPORTED_TYPE; + return decoderFactory.supportsFormat(format) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; + } + + @Override + protected void onStreamChanged(Format[] formats) throws ExoPlaybackException { + decoder = decoderFactory.createDecoder(formats[0]); } @Override @@ -97,12 +113,16 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { if (result == C.RESULT_BUFFER_READ) { if (buffer.isEndOfStream()) { inputStreamEnded = true; + } else if (buffer.isDecodeOnly()) { + // Do nothing. Note this assumes that all metadata buffers can be decoded independently. + // If we ever need to support a metadata format where this is not the case, we'll need to + // pass the buffer to the decoder and discard the output. } else { pendingMetadataTimestamp = buffer.timeUs; + buffer.subsampleOffsetUs = formatHolder.format.subsampleOffsetUs; + buffer.flip(); try { - buffer.flip(); - ByteBuffer bufferData = buffer.data; - pendingMetadata = metadataDecoder.decode(bufferData.array(), bufferData.limit()); + pendingMetadata = decoder.decode(buffer); } catch (MetadataDecoderException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } @@ -119,6 +139,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { @Override protected void onDisabled() { pendingMetadata = null; + decoder = null; super.onDisabled(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessage.java b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessage.java new file mode 100644 index 0000000000..9d6d0af60c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessage.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2017 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.emsg; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * An Event Message (emsg) as defined in ISO 23009-1. + */ +public final class EventMessage implements Metadata.Entry { + + /** + * The message scheme. + */ + public final String schemeIdUri; + + /** + * The value for the event. + */ + public final String value; + + /** + * The duration of the event in milliseconds. + */ + public final long durationMs; + + /** + * The instance identifier. + */ + public final long id; + + /** + * The body of the message. + */ + public final byte[] messageData; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * + * @param schemeIdUri The message scheme. + * @param value The value for the event. + * @param durationMs The duration of the event in milliseconds. + * @param id The instance identifier. + * @param messageData The body of the message. + */ + public EventMessage(String schemeIdUri, String value, long durationMs, long id, + byte[] messageData) { + this.schemeIdUri = schemeIdUri; + this.value = value; + this.durationMs = durationMs; + this.id = id; + this.messageData = messageData; + } + + /* package */ EventMessage(Parcel in) { + schemeIdUri = in.readString(); + value = in.readString(); + durationMs = in.readLong(); + id = in.readLong(); + messageData = in.createByteArray(); + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + (schemeIdUri != null ? schemeIdUri.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); + result = 31 * result + (int) (durationMs ^ (durationMs >>> 32)); + result = 31 * result + (int) (id ^ (id >>> 32)); + result = 31 * result + Arrays.hashCode(messageData); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + EventMessage other = (EventMessage) obj; + return durationMs == other.durationMs && id == other.id + && Util.areEqual(schemeIdUri, other.schemeIdUri) && Util.areEqual(value, other.value) + && Arrays.equals(messageData, other.messageData); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(schemeIdUri); + dest.writeString(value); + dest.writeLong(durationMs); + dest.writeLong(id); + dest.writeByteArray(messageData); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public EventMessage createFromParcel(Parcel in) { + return new EventMessage(in); + } + + @Override + public EventMessage[] newArray(int size) { + return new EventMessage[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java new file mode 100644 index 0000000000..fd6996aa80 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2017 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.emsg; + +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.MetadataDecoder; +import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * Decodes Event Message (emsg) atoms, as defined in ISO 23009-1. + *

    + * Atom data should be provided to the decoder without the full atom header (i.e. starting from the + * first byte of the scheme_id_uri field). + */ +public final class EventMessageDecoder implements MetadataDecoder { + + @Override + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = inputBuffer.data; + byte[] data = buffer.array(); + int size = buffer.limit(); + ParsableByteArray emsgData = new ParsableByteArray(data, size); + String schemeIdUri = emsgData.readNullTerminatedString(); + String value = emsgData.readNullTerminatedString(); + long timescale = emsgData.readUnsignedInt(); + emsgData.skipBytes(4); // presentation_time_delta + long durationMs = (emsgData.readUnsignedInt() * 1000) / timescale; + long id = emsgData.readUnsignedInt(); + byte[] messageData = Arrays.copyOfRange(data, emsgData.getPosition(), size); + return new Metadata(new EventMessage(schemeIdUri, value, durationMs, id, messageData)); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java new file mode 100644 index 0000000000..c82f982aa7 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2017 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 com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Chapter information ID3 frame. + */ +public final class ChapterFrame extends Id3Frame { + + public static final String ID = "CHAP"; + + public final String chapterId; + public final int startTimeMs; + public final int endTimeMs; + /** + * The byte offset of the start of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long startOffset; + /** + * The byte offset of the end of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long endOffset; + private final Id3Frame[] subFrames; + + public ChapterFrame(String chapterId, int startTimeMs, int endTimeMs, long startOffset, + long endOffset, Id3Frame[] subFrames) { + super(ID); + this.chapterId = chapterId; + this.startTimeMs = startTimeMs; + this.endTimeMs = endTimeMs; + this.startOffset = startOffset; + this.endOffset = endOffset; + this.subFrames = subFrames; + } + + /* package */ ChapterFrame(Parcel in) { + super(ID); + this.chapterId = in.readString(); + this.startTimeMs = in.readInt(); + this.endTimeMs = in.readInt(); + this.startOffset = in.readLong(); + this.endOffset = in.readLong(); + int subFrameCount = in.readInt(); + subFrames = new Id3Frame[subFrameCount]; + for (int i = 0; i < subFrameCount; i++) { + subFrames[i] = in.readParcelable(Id3Frame.class.getClassLoader()); + } + } + + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ChapterFrame other = (ChapterFrame) obj; + return startTimeMs == other.startTimeMs + && endTimeMs == other.endTimeMs + && startOffset == other.startOffset + && endOffset == other.endOffset + && Util.areEqual(chapterId, other.chapterId) + && Arrays.equals(subFrames, other.subFrames); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + startTimeMs; + result = 31 * result + endTimeMs; + result = 31 * result + (int) startOffset; + result = 31 * result + (int) endOffset; + result = 31 * result + (chapterId != null ? chapterId.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(chapterId); + dest.writeInt(startTimeMs); + dest.writeInt(endTimeMs); + dest.writeLong(startOffset); + dest.writeLong(endOffset); + dest.writeInt(subFrames.length); + for (Id3Frame subFrame : subFrames) { + dest.writeParcelable(subFrame, 0); + } + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + + @Override + public ChapterFrame createFromParcel(Parcel in) { + return new ChapterFrame(in); + } + + @Override + public ChapterFrame[] newArray(int size) { + return new ChapterFrame[size]; + } + + }; + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java new file mode 100644 index 0000000000..d71d0863c7 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2017 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 com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Chapter table of contents ID3 frame. + */ +public final class ChapterTocFrame extends Id3Frame { + + public static final String ID = "CTOC"; + + public final String elementId; + public final boolean isRoot; + public final boolean isOrdered; + public final String[] children; + private final Id3Frame[] subFrames; + + public ChapterTocFrame(String elementId, boolean isRoot, boolean isOrdered, String[] children, + Id3Frame[] subFrames) { + super(ID); + this.elementId = elementId; + this.isRoot = isRoot; + this.isOrdered = isOrdered; + this.children = children; + this.subFrames = subFrames; + } + + /* package */ ChapterTocFrame(Parcel in) { + super(ID); + this.elementId = in.readString(); + this.isRoot = in.readByte() != 0; + this.isOrdered = in.readByte() != 0; + this.children = in.createStringArray(); + int subFrameCount = in.readInt(); + subFrames = new Id3Frame[subFrameCount]; + for (int i = 0; i < subFrameCount; i++) { + subFrames[i] = in.readParcelable(Id3Frame.class.getClassLoader()); + } + } + + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ChapterTocFrame other = (ChapterTocFrame) obj; + return isRoot == other.isRoot + && isOrdered == other.isOrdered + && Util.areEqual(elementId, other.elementId) + && Arrays.equals(children, other.children) + && Arrays.equals(subFrames, other.subFrames); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (isRoot ? 1 : 0); + result = 31 * result + (isOrdered ? 1 : 0); + result = 31 * result + (elementId != null ? elementId.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(elementId); + dest.writeByte((byte) (isRoot ? 1 : 0)); + dest.writeByte((byte) (isOrdered ? 1 : 0)); + dest.writeStringArray(children); + dest.writeInt(subFrames.length); + for (int i = 0; i < subFrames.length; i++) { + dest.writeParcelable(subFrames[i], 0); + } + } + + public static final Creator CREATOR = new Creator() { + + @Override + public ChapterTocFrame createFromParcel(Parcel in) { + return new ChapterTocFrame(in); + } + + @Override + public ChapterTocFrame[] newArray(int size) { + return new ChapterTocFrame[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 d27c4f06e9..16059ccfbf 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 @@ -16,12 +16,14 @@ package com.google.android.exoplayer2.metadata.id3; import android.util.Log; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoder; -import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -49,11 +51,18 @@ public final class Id3Decoder implements MetadataDecoder { private static final int ID3_TEXT_ENCODING_UTF_8 = 3; @Override - public boolean canDecode(String mimeType) { - return mimeType.equals(MimeTypes.APPLICATION_ID3); + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = inputBuffer.data; + return decode(buffer.array(), buffer.limit()); } - @Override + /** + * Decodes ID3 tags. + * + * @param data The bytes to decode ID3 tags from. + * @param size Amount of bytes in {@code data} to read. + * @return A {@link Metadata} object containing the decoded ID3 tags. + */ public Metadata decode(byte[] data, int size) { List id3Frames = new ArrayList<>(); ParsableByteArray id3Data = new ParsableByteArray(data, size); @@ -84,7 +93,8 @@ public final class Id3Decoder implements MetadataDecoder { int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10; while (id3Data.bytesLeft() >= frameHeaderSize) { - Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack); + Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize); if (frame != null) { id3Frames.add(frame); } @@ -190,7 +200,7 @@ public final class Id3Decoder implements MetadataDecoder { } private static Id3Frame decodeFrame(int majorVersion, ParsableByteArray id3Data, - boolean unsignedIntFrameSizeHack) { + boolean unsignedIntFrameSizeHack, int frameHeaderSize) { int frameId0 = id3Data.readUnsignedByte(); int frameId1 = id3Data.readUnsignedByte(); int frameId2 = id3Data.readUnsignedByte(); @@ -266,6 +276,19 @@ public final class Id3Decoder implements MetadataDecoder { if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && (majorVersion == 2 || frameId3 == 'X')) { frame = decodeTxxxFrame(id3Data, frameSize); + } 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 == 'W' && frameId1 == 'X' && frameId2 == 'X' + && (majorVersion == 2 || frameId3 == 'X')) { + frame = decodeWxxxFrame(id3Data, frameSize); + } else if (frameId0 == 'W') { + 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 = decodeUrlLinkFrame(id3Data, frameSize, id); } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { frame = decodePrivFrame(id3Data, frameSize); } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' @@ -274,14 +297,15 @@ public final class Id3Decoder implements MetadataDecoder { } 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 if (frameId0 == 'C' && frameId1 == 'H' && frameId2 == 'A' && frameId3 == 'P') { + frame = decodeChapterFrame(id3Data, frameSize, majorVersion, unsignedIntFrameSizeHack, + frameHeaderSize); + } else if (frameId0 == 'C' && frameId1 == 'T' && frameId2 == 'O' && frameId3 == 'C') { + frame = decodeChapterTOCFrame(id3Data, frameSize, majorVersion, unsignedIntFrameSizeHack, + frameHeaderSize); } else { String id = majorVersion == 2 ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) @@ -297,7 +321,7 @@ public final class Id3Decoder implements MetadataDecoder { } } - private static TxxxFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) + private static TextInformationFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) throws UnsupportedEncodingException { int encoding = id3Data.readUnsignedByte(); String charset = getCharsetName(encoding); @@ -308,11 +332,74 @@ public final class Id3Decoder implements MetadataDecoder { int descriptionEndIndex = indexOfEos(data, 0, encoding); String description = new String(data, 0, descriptionEndIndex, charset); + String value; int valueStartIndex = descriptionEndIndex + delimiterLength(encoding); - int valueEndIndex = indexOfEos(data, valueStartIndex, encoding); - String value = new String(data, valueStartIndex, valueEndIndex - valueStartIndex, charset); + if (valueStartIndex < data.length) { + int valueEndIndex = indexOfEos(data, valueStartIndex, encoding); + value = new String(data, valueStartIndex, valueEndIndex - valueStartIndex, charset); + } else { + value = ""; + } - return new TxxxFrame(description, value); + return new TextInformationFrame("TXXX", description, value); + } + + private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data, + int frameSize, String id) throws UnsupportedEncodingException { + if (frameSize <= 1) { + // Frame is empty or contains only the text encoding byte. + return new TextInformationFrame(id, null, ""); + } + + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int valueEndIndex = indexOfEos(data, 0, encoding); + String value = new String(data, 0, valueEndIndex, charset); + + return new TextInformationFrame(id, null, value); + } + + private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + String url; + int urlStartIndex = descriptionEndIndex + delimiterLength(encoding); + if (urlStartIndex < data.length) { + int urlEndIndex = indexOfZeroByte(data, urlStartIndex); + url = new String(data, urlStartIndex, urlEndIndex - urlStartIndex, "ISO-8859-1"); + } else { + url = ""; + } + + return new UrlLinkFrame("WXXX", description, url); + } + + private static UrlLinkFrame decodeUrlLinkFrame(ParsableByteArray id3Data, int frameSize, + String id) throws UnsupportedEncodingException { + if (frameSize == 0) { + // Frame is empty. + return new UrlLinkFrame(id, null, ""); + } + + byte[] data = new byte[frameSize]; + id3Data.readBytes(data, 0, frameSize); + + int urlEndIndex = indexOfZeroByte(data, 0); + String url = new String(data, 0, urlEndIndex, "ISO-8859-1"); + + return new UrlLinkFrame(id, null, url); } private static PrivFrame decodePrivFrame(ParsableByteArray id3Data, int frameSize) @@ -408,25 +495,88 @@ public final class Id3Decoder implements MetadataDecoder { int descriptionEndIndex = indexOfEos(data, 0, encoding); String description = new String(data, 0, descriptionEndIndex, charset); + String text; int textStartIndex = descriptionEndIndex + delimiterLength(encoding); - int textEndIndex = indexOfEos(data, textStartIndex, encoding); - String text = new String(data, textStartIndex, textEndIndex - textStartIndex, charset); + if (textStartIndex < data.length) { + int textEndIndex = indexOfEos(data, textStartIndex, encoding); + text = new String(data, textStartIndex, textEndIndex - textStartIndex, charset); + } else { + text = ""; + } return new CommentFrame(language, description, text); } - private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data, - int frameSize, String id) throws UnsupportedEncodingException { - int encoding = id3Data.readUnsignedByte(); - String charset = getCharsetName(encoding); + private static ChapterFrame decodeChapterFrame(ParsableByteArray id3Data, int frameSize, + int majorVersion, boolean unsignedIntFrameSizeHack, int frameHeaderSize) + throws UnsupportedEncodingException { + int framePosition = id3Data.getPosition(); + int chapterIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); + String chapterId = new String(id3Data.data, framePosition, chapterIdEndIndex - framePosition, + "ISO-8859-1"); + id3Data.setPosition(chapterIdEndIndex + 1); - byte[] data = new byte[frameSize - 1]; - id3Data.readBytes(data, 0, frameSize - 1); + int startTime = id3Data.readInt(); + int endTime = id3Data.readInt(); + long startOffset = id3Data.readUnsignedInt(); + if (startOffset == 0xFFFFFFFFL) { + startOffset = C.POSITION_UNSET; + } + long endOffset = id3Data.readUnsignedInt(); + if (endOffset == 0xFFFFFFFFL) { + endOffset = C.POSITION_UNSET; + } - int descriptionEndIndex = indexOfEos(data, 0, encoding); - String description = new String(data, 0, descriptionEndIndex, charset); + ArrayList subFrames = new ArrayList<>(); + int limit = framePosition + frameSize; + while (id3Data.getPosition() < limit) { + Id3Frame frame = decodeFrame(majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize); + if (frame != null) { + subFrames.add(frame); + } + } - return new TextInformationFrame(id, description); + Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; + subFrames.toArray(subFrameArray); + return new ChapterFrame(chapterId, startTime, endTime, startOffset, endOffset, subFrameArray); + } + + private static ChapterTocFrame decodeChapterTOCFrame(ParsableByteArray id3Data, int frameSize, + int majorVersion, boolean unsignedIntFrameSizeHack, int frameHeaderSize) + throws UnsupportedEncodingException { + int framePosition = id3Data.getPosition(); + int elementIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); + String elementId = new String(id3Data.data, framePosition, elementIdEndIndex - framePosition, + "ISO-8859-1"); + id3Data.setPosition(elementIdEndIndex + 1); + + int ctocFlags = id3Data.readUnsignedByte(); + boolean isRoot = (ctocFlags & 0x0002) != 0; + boolean isOrdered = (ctocFlags & 0x0001) != 0; + + int childCount = id3Data.readUnsignedByte(); + String[] children = new String[childCount]; + for (int i = 0; i < childCount; i++) { + int startIndex = id3Data.getPosition(); + int endIndex = indexOfZeroByte(id3Data.data, startIndex); + children[i] = new String(id3Data.data, startIndex, endIndex - startIndex, "ISO-8859-1"); + id3Data.setPosition(endIndex + 1); + } + + ArrayList subFrames = new ArrayList<>(); + int limit = framePosition + frameSize; + while (id3Data.getPosition() < limit) { + Id3Frame frame = decodeFrame(majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize); + if (frame != null) { + subFrames.add(frame); + } + } + + Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; + subFrames.toArray(subFrameArray); + return new ChapterTocFrame(elementId, isRoot, isOrdered, children, subFrameArray); } private static BinaryFrame decodeBinaryFrame(ParsableByteArray id3Data, int frameSize, @@ -458,6 +608,7 @@ public final class Id3Decoder implements MetadataDecoder { /** * Maps encoding byte from ID3v2 frame to a Charset. + * * @param encodingByte The value of encoding byte from ID3v2 frame. * @return Charset name. */ 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 b8c061fd0a..6221062e33 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 @@ -20,20 +20,23 @@ import android.os.Parcelable; import com.google.android.exoplayer2.util.Util; /** - * Text information ("T000" - "TZZZ", excluding "TXXX") ID3 frame. + * Text information ID3 frame. */ public final class TextInformationFrame extends Id3Frame { public final String description; + public final String value; - public TextInformationFrame(String id, String description) { + public TextInformationFrame(String id, String description, String value) { super(id); this.description = description; + this.value = value; } /* package */ TextInformationFrame(Parcel in) { super(in.readString()); description = in.readString(); + value = in.readString(); } @Override @@ -45,7 +48,8 @@ public final class TextInformationFrame extends Id3Frame { return false; } TextInformationFrame other = (TextInformationFrame) obj; - return id.equals(other.id) && Util.areEqual(description, other.description); + return id.equals(other.id) && Util.areEqual(description, other.description) + && Util.areEqual(value, other.value); } @Override @@ -53,6 +57,7 @@ public final class TextInformationFrame extends Id3Frame { int result = 17; result = 31 * result + id.hashCode(); result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); return result; } @@ -60,6 +65,7 @@ public final class TextInformationFrame extends Id3Frame { public void writeToParcel(Parcel dest, int flags) { dest.writeString(id); dest.writeString(description); + dest.writeString(value); } public static final Parcelable.Creator CREATOR = 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/UrlLinkFrame.java similarity index 55% rename from library/src/main/java/com/google/android/exoplayer2/metadata/id3/TxxxFrame.java rename to library/src/main/java/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java index 5c24e70ef4..2148b921e1 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/UrlLinkFrame.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 The Android Open Source Project + * Copyright (C) 2017 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. @@ -20,25 +20,23 @@ import android.os.Parcelable; import com.google.android.exoplayer2.util.Util; /** - * TXXX (User defined text information) ID3 frame. + * Url link ID3 frame. */ -public final class TxxxFrame extends Id3Frame { - - public static final String ID = "TXXX"; +public final class UrlLinkFrame extends Id3Frame { public final String description; - public final String value; + public final String url; - public TxxxFrame(String description, String value) { - super(ID); + public UrlLinkFrame(String id, String description, String url) { + super(id); this.description = description; - this.value = value; + this.url = url; } - /* package */ TxxxFrame(Parcel in) { - super(ID); + /* package */ UrlLinkFrame(Parcel in) { + super(in.readString()); description = in.readString(); - value = in.readString(); + url = in.readString(); } @Override @@ -49,36 +47,40 @@ public final class TxxxFrame extends Id3Frame { if (obj == null || getClass() != obj.getClass()) { return false; } - TxxxFrame other = (TxxxFrame) obj; - return Util.areEqual(description, other.description) && Util.areEqual(value, other.value); + UrlLinkFrame other = (UrlLinkFrame) obj; + return id.equals(other.id) && Util.areEqual(description, other.description) + && Util.areEqual(url, other.url); } @Override public int hashCode() { int result = 17; + result = 31 * result + id.hashCode(); result = 31 * result + (description != null ? description.hashCode() : 0); - result = 31 * result + (value != null ? value.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); return result; } @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); dest.writeString(description); - dest.writeString(value); + dest.writeString(url); } - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { - @Override - public TxxxFrame createFromParcel(Parcel in) { - return new TxxxFrame(in); - } + @Override + public UrlLinkFrame createFromParcel(Parcel in) { + return new UrlLinkFrame(in); + } - @Override - public TxxxFrame[] newArray(int size) { - return new TxxxFrame[size]; - } + @Override + public UrlLinkFrame[] newArray(int size) { + return new UrlLinkFrame[size]; + } - }; + }; } 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 index 5af0f25481..6e373a45e7 100644 --- 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 @@ -15,13 +15,13 @@ */ 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.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; /** * Decodes splice info sections and produces splice commands. @@ -43,12 +43,10 @@ public final class SpliceInfoDecoder implements MetadataDecoder { } @Override - public boolean canDecode(String mimeType) { - return TextUtils.equals(mimeType, MimeTypes.APPLICATION_SCTE35); - } - - @Override - public Metadata decode(byte[] data, int size) throws MetadataDecoderException { + public Metadata decode(MetadataInputBuffer inputBuffer) throws MetadataDecoderException { + ByteBuffer buffer = inputBuffer.data; + byte[] data = buffer.array(); + int size = buffer.limit(); sectionData.reset(data, size); sectionHeader.reset(data, size); // table_id(8), section_syntax_indicator(1), private_indicator(1), reserved(2), diff --git a/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java index c39bccda3d..b18eabf493 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -26,10 +26,12 @@ import java.io.IOException; * Wraps a {@link MediaPeriod} and clips its {@link SampleStream}s to provide a subsequence of their * samples. */ -/* package */ final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { +public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + /** + * The {@link MediaPeriod} wrapped by this clipping media period. + */ public final MediaPeriod mediaPeriod; - private final ClippingMediaSource mediaSource; private MediaPeriod.Callback callback; private long startUs; @@ -40,18 +42,31 @@ import java.io.IOException; /** * Creates a new clipping media period that provides a clipped view of the specified * {@link MediaPeriod}'s sample streams. + *

    + * The clipping start/end positions must be specified by calling {@link #setClipping(long, long)} + * on the playback thread before preparation completes. * * @param mediaPeriod The media period to clip. - * @param mediaSource The {@link ClippingMediaSource} to which this period belongs. */ - public ClippingMediaPeriod(MediaPeriod mediaPeriod, ClippingMediaSource mediaSource) { + public ClippingMediaPeriod(MediaPeriod mediaPeriod) { this.mediaPeriod = mediaPeriod; - this.mediaSource = mediaSource; startUs = C.TIME_UNSET; endUs = C.TIME_UNSET; sampleStreams = new ClippingSampleStream[0]; } + /** + * Sets the clipping start/end times for this period, in microseconds. + * + * @param startUs The clipping start time, in microseconds. + * @param endUs The clipping end time, in microseconds, or {@link C#TIME_END_OF_SOURCE} to + * indicate the end of the period. + */ + public void setClipping(long startUs, long endUs) { + this.startUs = startUs; + this.endUs = endUs; + } + @Override public void prepare(MediaPeriod.Callback callback) { this.callback = callback; @@ -80,7 +95,8 @@ import java.io.IOException; long enablePositionUs = mediaPeriod.selectTracks(selections, mayRetainStreamFlags, internalStreams, streamResetFlags, positionUs + startUs); Assertions.checkState(enablePositionUs == positionUs + startUs - || (enablePositionUs >= startUs && enablePositionUs <= endUs)); + || (enablePositionUs >= startUs + && (endUs == C.TIME_END_OF_SOURCE || enablePositionUs <= endUs))); for (int i = 0; i < streams.length; i++) { if (internalStreams[i] == null) { sampleStreams[i] = null; @@ -110,14 +126,16 @@ import java.io.IOException; if (discontinuityUs == C.TIME_UNSET) { return C.TIME_UNSET; } - Assertions.checkState(discontinuityUs >= startUs && discontinuityUs <= endUs); + Assertions.checkState(discontinuityUs >= startUs); + Assertions.checkState(endUs == C.TIME_END_OF_SOURCE || discontinuityUs <= endUs); return discontinuityUs - startUs; } @Override public long getBufferedPositionUs() { long bufferedPositionUs = mediaPeriod.getBufferedPositionUs(); - if (bufferedPositionUs == C.TIME_END_OF_SOURCE || bufferedPositionUs >= endUs) { + if (bufferedPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && bufferedPositionUs >= endUs)) { return C.TIME_END_OF_SOURCE; } return Math.max(0, bufferedPositionUs - startUs); @@ -131,14 +149,16 @@ import java.io.IOException; } } long seekUs = mediaPeriod.seekToUs(positionUs + startUs); - Assertions.checkState(seekUs == positionUs + startUs || (seekUs >= startUs && seekUs <= endUs)); + Assertions.checkState(seekUs == positionUs + startUs + || (seekUs >= startUs && (endUs == C.TIME_END_OF_SOURCE || seekUs <= endUs))); return seekUs - startUs; } @Override public long getNextLoadPositionUs() { long nextLoadPositionUs = mediaPeriod.getNextLoadPositionUs(); - if (nextLoadPositionUs == C.TIME_END_OF_SOURCE || nextLoadPositionUs >= endUs) { + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && nextLoadPositionUs >= endUs)) { return C.TIME_END_OF_SOURCE; } return nextLoadPositionUs - startUs; @@ -153,8 +173,6 @@ import java.io.IOException; @Override public void onPrepared(MediaPeriod mediaPeriod) { - startUs = mediaSource.getStartUs(); - endUs = mediaSource.getEndUs(); Assertions.checkState(startUs != C.TIME_UNSET && endUs != C.TIME_UNSET); // If the clipping start position is non-zero, the clipping sample streams will adjust // timestamps on buffers they read from the unclipped sample streams. These adjusted buffer @@ -217,21 +235,24 @@ import java.io.IOException; if (pendingDiscontinuity) { return C.RESULT_NOTHING_READ; } + if (buffer == null) { + return stream.readData(formatHolder, null); + } if (sentEos) { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; } int result = stream.readData(formatHolder, buffer); // TODO: Clear gapless playback metadata if a format was read (if applicable). - if ((result == C.RESULT_BUFFER_READ && buffer.timeUs >= endUs) - || (result == C.RESULT_NOTHING_READ - && mediaPeriod.getBufferedPositionUs() == C.TIME_END_OF_SOURCE)) { + if (endUs != C.TIME_END_OF_SOURCE && ((result == C.RESULT_BUFFER_READ + && buffer.timeUs >= endUs) || (result == C.RESULT_NOTHING_READ + && mediaPeriod.getBufferedPositionUs() == C.TIME_END_OF_SOURCE))) { buffer.clear(); buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); sentEos = true; return C.RESULT_BUFFER_READ; } - if (result == C.RESULT_BUFFER_READ) { + if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream()) { buffer.timeUs -= startUs; } return result; diff --git a/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java b/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java index e92dce8231..be15a07726 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -21,17 +21,19 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; +import java.util.ArrayList; /** * {@link MediaSource} that wraps a source and clips its timeline based on specified start/end * positions. The wrapped source may only have a single period/window and it must not be dynamic - * (live). The specified start position must correspond to a synchronization sample in the period. + * (live). */ public final class ClippingMediaSource implements MediaSource, MediaSource.Listener { private final MediaSource mediaSource; private final long startUs; private final long endUs; + private final ArrayList mediaPeriods; private MediaSource.Listener sourceListener; private ClippingTimeline clippingTimeline; @@ -51,20 +53,7 @@ public final class ClippingMediaSource implements MediaSource, MediaSource.Liste this.mediaSource = Assertions.checkNotNull(mediaSource); startUs = startPositionUs; endUs = endPositionUs; - } - - /** - * Returns the start position of the clipping source's timeline in microseconds. - */ - /* package */ long getStartUs() { - return clippingTimeline.startUs; - } - - /** - * Returns the end position of the clipping source's timeline in microseconds. - */ - /* package */ long getEndUs() { - return clippingTimeline.endUs; + mediaPeriods = new ArrayList<>(); } @Override @@ -80,12 +69,16 @@ public final class ClippingMediaSource implements MediaSource, MediaSource.Liste @Override public MediaPeriod createPeriod(int index, Allocator allocator, long positionUs) { - return new ClippingMediaPeriod( - mediaSource.createPeriod(index, allocator, startUs + positionUs), this); + ClippingMediaPeriod mediaPeriod = new ClippingMediaPeriod( + mediaSource.createPeriod(index, allocator, startUs + positionUs)); + mediaPeriods.add(mediaPeriod); + mediaPeriod.setClipping(clippingTimeline.startUs, clippingTimeline.endUs); + return mediaPeriod; } @Override public void releasePeriod(MediaPeriod mediaPeriod) { + Assertions.checkState(mediaPeriods.remove(mediaPeriod)); mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); } @@ -100,6 +93,13 @@ public final class ClippingMediaSource implements MediaSource, MediaSource.Liste public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { clippingTimeline = new ClippingTimeline(timeline, startUs, endUs); sourceListener.onSourceInfoRefreshed(clippingTimeline, manifest); + long startUs = clippingTimeline.startUs; + long endUs = clippingTimeline.endUs == C.TIME_UNSET ? C.TIME_END_OF_SOURCE + : clippingTimeline.endUs; + int count = mediaPeriods.size(); + for (int i = 0; i < count; i++) { + mediaPeriods.get(i).setClipping(startUs, endUs); + } } /** @@ -112,7 +112,7 @@ public final class ClippingMediaSource implements MediaSource, MediaSource.Liste private final long endUs; /** - * Creates a new timeline that wraps the specified timeline. + * Creates a new clipping timeline that wraps the specified timeline. * * @param timeline The timeline to clip. * @param startUs The number of microseconds to clip from the start of {@code timeline}. 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 8ab4d45c47..bc0a3f1cf8 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 @@ -40,6 +40,7 @@ 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.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; @@ -62,6 +63,7 @@ import java.io.IOException; private final ExtractorMediaSource.EventListener eventListener; private final MediaSource.Listener sourceListener; private final Allocator allocator; + private final String customCacheKey; private final Loader loader; private final ExtractorHolder extractorHolder; private final ConditionVariable loadCondition; @@ -101,11 +103,13 @@ import java.io.IOException; * @param eventListener A listener of events. May be null if delivery of events is not required. * @param sourceListener A listener to notify when the timeline has been loaded. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. */ public ExtractorMediaPeriod(Uri uri, DataSource dataSource, Extractor[] extractors, int minLoadableRetryCount, Handler eventHandler, ExtractorMediaSource.EventListener eventListener, MediaSource.Listener sourceListener, - Allocator allocator) { + Allocator allocator, String customCacheKey) { this.uri = uri; this.dataSource = dataSource; this.minLoadableRetryCount = minLoadableRetryCount; @@ -113,6 +117,7 @@ import java.io.IOException; this.eventListener = eventListener; this.sourceListener = sourceListener; this.allocator = allocator; + this.customCacheKey = customCacheKey; loader = new Loader("Loader:ExtractorMediaPeriod"); extractorHolder = new ExtractorHolder(extractors, this); loadCondition = new ConditionVariable(); @@ -615,7 +620,7 @@ import java.io.IOException; ExtractorInput input = null; try { long position = positionHolder.position; - length = dataSource.open(new DataSpec(uri, position, C.LENGTH_UNSET, null)); + length = dataSource.open(new DataSpec(uri, position, C.LENGTH_UNSET, customCacheKey)); if (length != C.LENGTH_UNSET) { length += position; } @@ -640,7 +645,7 @@ import java.io.IOException; } else if (input != null) { positionHolder.position = input.getPosition(); } - dataSource.close(); + Util.closeQuietly(dataSource); } } } 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 559d241598..7b571bc289 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 @@ -93,6 +93,7 @@ public final class ExtractorMediaSource implements MediaSource, MediaSource.List private final Handler eventHandler; private final EventListener eventListener; private final Timeline.Period period; + private final String customCacheKey; private MediaSource.Listener sourceListener; private Timeline timeline; @@ -110,7 +111,25 @@ public final class ExtractorMediaSource implements MediaSource, MediaSource.List public ExtractorMediaSource(Uri uri, DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory, Handler eventHandler, EventListener eventListener) { this(uri, dataSourceFactory, extractorsFactory, MIN_RETRY_COUNT_DEFAULT_FOR_MEDIA, eventHandler, - eventListener); + eventListener, null); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @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. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + */ + public ExtractorMediaSource(Uri uri, DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, Handler eventHandler, EventListener eventListener, + String customCacheKey) { + this(uri, dataSourceFactory, extractorsFactory, MIN_RETRY_COUNT_DEFAULT_FOR_MEDIA, eventHandler, + eventListener, customCacheKey); } /** @@ -122,16 +141,19 @@ public final class ExtractorMediaSource implements MediaSource, MediaSource.List * @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. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. */ public ExtractorMediaSource(Uri uri, DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory, int minLoadableRetryCount, Handler eventHandler, - EventListener eventListener) { + EventListener eventListener, String customCacheKey) { this.uri = uri; this.dataSourceFactory = dataSourceFactory; this.extractorsFactory = extractorsFactory; this.minLoadableRetryCount = minLoadableRetryCount; this.eventHandler = eventHandler; this.eventListener = eventListener; + this.customCacheKey = customCacheKey; period = new Timeline.Period(); } @@ -152,7 +174,7 @@ public final class ExtractorMediaSource implements MediaSource, MediaSource.List Assertions.checkArgument(index == 0); return new ExtractorMediaPeriod(uri, dataSourceFactory.createDataSource(), extractorsFactory.createExtractors(), minLoadableRetryCount, eventHandler, eventListener, - this, allocator); + this, allocator, customCacheKey); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index f4a9665b10..31ee8df1e4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.trackselection.TrackSelection; import java.io.IOException; @@ -47,6 +48,10 @@ public interface MediaPeriod extends SequenceableLoader { *

    * {@code callback.onPrepared} is called when preparation completes. If preparation fails, * {@link #maybeThrowPrepareError()} will throw an {@link IOException}. + *

    + * If preparation succeeds and results in a source timeline change (e.g. the period duration + * becoming known), {@link MediaSource.Listener#onSourceInfoRefreshed(Timeline, Object)} will be + * called before {@code callback.onPrepared}. * * @param callback Callback to receive updates from this period, including being notified when * preparation completes. diff --git a/library/src/main/java/com/google/android/exoplayer2/source/SampleStream.java b/library/src/main/java/com/google/android/exoplayer2/source/SampleStream.java index 39374acb33..5ee70cd2ed 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/SampleStream.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/SampleStream.java @@ -44,11 +44,17 @@ public interface SampleStream { /** * Attempts to read from the stream. + *

    + * If no data is available then {@link C#RESULT_NOTHING_READ} is returned. If the format of the + * media is changing or if {@code buffer == null} then {@code formatHolder} is populated and + * {@link C#RESULT_FORMAT_READ} is returned. Else {@code buffer} is populated and + * {@link C#RESULT_BUFFER_READ} is returned. * * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the * end of the stream. If the end of the stream has been reached, the - * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. + * {@link C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. May be null if the + * caller requires that the format of the stream be read even if it's not changing. * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or * {@link C#RESULT_BUFFER_READ}. */ diff --git a/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java index 1ad448bd12..c78bb5371b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java @@ -28,6 +28,7 @@ import com.google.android.exoplayer2.upstream.DataSpec; 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.Util; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -205,13 +206,13 @@ import java.util.Arrays; @Override public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer) { - if (streamState == STREAM_STATE_END_OF_STREAM) { - buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); - return C.RESULT_BUFFER_READ; - } else if (streamState == STREAM_STATE_SEND_FORMAT) { + if (buffer == null || streamState == STREAM_STATE_SEND_FORMAT) { formatHolder.format = format; streamState = STREAM_STATE_SEND_SAMPLE; return C.RESULT_FORMAT_READ; + } else if (streamState == STREAM_STATE_END_OF_STREAM) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; } Assertions.checkState(streamState == STREAM_STATE_SEND_SAMPLE); @@ -276,7 +277,7 @@ import java.util.Arrays; result = dataSource.read(sampleData, sampleSize, sampleData.length - sampleSize); } } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } } 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 ed76a505ea..2623d31cef 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 @@ -30,15 +30,15 @@ import java.io.IOException; /** * An {@link Extractor} wrapper for loading chunks containing a single track. *

    - * The wrapper allows switching of the {@link SingleTrackMetadataOutput} and {@link TrackOutput} - * which receive parsed data. + * The wrapper allows switching of the {@link SeekMapOutput} and {@link TrackOutput} that receive + * parsed data. */ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput { /** - * Receives metadata associated with the track as extracted by the wrapped {@link Extractor}. + * Receives {@link SeekMap}s extracted by the wrapped {@link Extractor}. */ - public interface SingleTrackMetadataOutput { + public interface SeekMapOutput { /** * @see ExtractorOutput#seekMap(SeekMap) @@ -47,13 +47,14 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } - private final Extractor extractor; + public final Extractor extractor; + private final Format manifestFormat; private final boolean preferManifestDrmInitData; private final boolean resendFormatOnInit; private boolean extractorInitialized; - private SingleTrackMetadataOutput metadataOutput; + private SeekMapOutput seekMapOutput; private TrackOutput trackOutput; private Format sentFormat; @@ -68,7 +69,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput * @param preferManifestDrmInitData Whether {@link DrmInitData} defined in {@code manifestFormat} * should be preferred when the sample and manifest {@link Format}s are merged. * @param resendFormatOnInit Whether the extractor should resend the previous {@link Format} when - * it is initialized via {@link #init(SingleTrackMetadataOutput, TrackOutput)}. + * it is initialized via {@link #init(SeekMapOutput, TrackOutput)}. */ public ChunkExtractorWrapper(Extractor extractor, Format manifestFormat, boolean preferManifestDrmInitData, boolean resendFormatOnInit) { @@ -79,14 +80,14 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } /** - * Initializes the extractor to output to the provided {@link SingleTrackMetadataOutput} and + * Initializes the extractor to output to the provided {@link SeekMapOutput} and * {@link TrackOutput} instances, and configures it to receive data from a new chunk. * - * @param metadataOutput The {@link SingleTrackMetadataOutput} that will receive metadata. + * @param seekMapOutput The {@link SeekMapOutput} that will receive extracted {@link SeekMap}s. * @param trackOutput The {@link TrackOutput} that will receive sample data. */ - public void init(SingleTrackMetadataOutput metadataOutput, TrackOutput trackOutput) { - this.metadataOutput = metadataOutput; + public void init(SeekMapOutput seekMapOutput, TrackOutput trackOutput) { + this.seekMapOutput = seekMapOutput; this.trackOutput = trackOutput; if (!extractorInitialized) { extractor.init(this); @@ -99,20 +100,6 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput } } - /** - * Reads from the provided {@link ExtractorInput}. - * - * @param input The {@link ExtractorInput} from which to read. - * @return One of {@link Extractor#RESULT_CONTINUE} and {@link Extractor#RESULT_END_OF_INPUT}. - * @throws IOException If an error occurred reading from the source. - * @throws InterruptedException If the thread was interrupted. - */ - public int read(ExtractorInput input) throws IOException, InterruptedException { - int result = extractor.read(input, null); - Assertions.checkState(result != Extractor.RESULT_SEEK); - return result; - } - // ExtractorOutput implementation. @Override @@ -130,7 +117,7 @@ public final class ChunkExtractorWrapper implements ExtractorOutput, TrackOutput @Override public void seekMap(SeekMap seekMap) { - metadataOutput.seekMap(seekMap); + seekMapOutput.seekMap(seekMap); } // TrackOutput implementation. 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 6de7c6ec01..3955d64034 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 @@ -122,7 +122,8 @@ public class ChunkSampleStream implements SampleStream, S public void seekToUs(long positionUs) { lastSeekPositionUs = positionUs; // If we're not pending a reset, see if we can seek within the sample queue. - boolean seekInsideBuffer = !isPendingReset() && sampleQueue.skipToKeyframeBefore(positionUs); + boolean seekInsideBuffer = !isPendingReset() + && sampleQueue.skipToKeyframeBefore(positionUs, positionUs < getNextLoadPositionUs()); if (seekInsideBuffer) { // We succeeded. All we need to do is discard any chunks that we've moved past. while (mediaChunks.size() > 1 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 a5af3cc42f..060e6130cf 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 @@ -21,16 +21,17 @@ import com.google.android.exoplayer2.extractor.DefaultTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.SeekMap; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SingleTrackMetadataOutput; +import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; /** * A {@link BaseMediaChunk} that uses an {@link Extractor} to decode sample data. */ -public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMetadataOutput { +public class ContainerMediaChunk extends BaseMediaChunk implements SeekMapOutput { private final int chunkCount; private final long sampleOffsetUs; @@ -85,7 +86,7 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMe return bytesLoaded; } - // SingleTrackMetadataOutput implementation. + // SeekMapOutput implementation. @Override public final void seekMap(SeekMap seekMap) { @@ -120,15 +121,17 @@ public class ContainerMediaChunk extends BaseMediaChunk implements SingleTrackMe } // Load and decode the sample data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } loadCompleted = true; } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/DataChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/DataChunk.java index 99653d323f..0846e7679d 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/DataChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/DataChunk.java @@ -19,6 +19,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; 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; import java.util.Arrays; @@ -96,7 +97,7 @@ public abstract class DataChunk extends Chunk { consume(data, limit); } } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index 388dc63899..c8c3389830 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -22,9 +22,10 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SingleTrackMetadataOutput; +import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.SeekMapOutput; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -32,7 +33,7 @@ import java.io.IOException; /** * A {@link Chunk} that uses an {@link Extractor} to decode initialization data for single track. */ -public final class InitializationChunk extends Chunk implements SingleTrackMetadataOutput, +public final class InitializationChunk extends Chunk implements SeekMapOutput, TrackOutput { private final ChunkExtractorWrapper extractorWrapper; @@ -85,7 +86,7 @@ public final class InitializationChunk extends Chunk implements SingleTrackMetad return seekMap; } - // SingleTrackMetadataOutput implementation. + // SeekMapOutput implementation. @Override public void seekMap(SeekMap seekMap) { @@ -142,15 +143,17 @@ public final class InitializationChunk extends Chunk implements SingleTrackMetad } // Load and decode the initialization data. try { + Extractor extractor = extractorWrapper.extractor; int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractorWrapper.read(input); + result = extractor.read(input, null); } + Assertions.checkState(result != Extractor.RESULT_SEEK); } finally { bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); } } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java index 3033566950..d7be74535e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java @@ -98,7 +98,7 @@ public final class SingleSampleMediaChunk extends BaseMediaChunk { int sampleSize = bytesLoaded; trackOutput.sampleMetadata(startTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); } finally { - dataSource.close(); + Util.closeQuietly(dataSource); } loadCompleted = true; } 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 9e48bc2c79..56ea626120 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 @@ -28,9 +28,8 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; /** * @param chunkIndex The {@link ChunkIndex} to wrap. - * @param uri The URI where the data is located. */ - public DashWrappingSegmentIndex(ChunkIndex chunkIndex, String uri) { + public DashWrappingSegmentIndex(ChunkIndex chunkIndex) { this.chunkIndex = chunkIndex; } 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 0e3d127796..74d53d3e32 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 @@ -185,10 +185,9 @@ public class DefaultDashChunkSource implements DashChunkSource { } if (pendingInitializationUri != null || pendingIndexUri != null) { // We have initialization and/or index requests to make. - Chunk initializationChunk = newInitializationChunk(representationHolder, dataSource, + out.chunk = newInitializationChunk(representationHolder, dataSource, trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(), trackSelection.getSelectionData(), pendingInitializationUri, pendingIndexUri); - out.chunk = initializationChunk; return; } @@ -233,10 +232,9 @@ public class DefaultDashChunkSource implements DashChunkSource { } int maxSegmentCount = Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1); - Chunk nextMediaChunk = newMediaChunk(representationHolder, dataSource, - trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(), - trackSelection.getSelectionData(), sampleFormat, segmentNum, maxSegmentCount); - out.chunk = nextMediaChunk; + out.chunk = newMediaChunk(representationHolder, dataSource, trackSelection.getSelectedFormat(), + trackSelection.getSelectionReason(), trackSelection.getSelectionData(), sampleFormat, + segmentNum, maxSegmentCount); } @Override @@ -255,8 +253,7 @@ public class DefaultDashChunkSource implements DashChunkSource { if (representationHolder.segmentIndex == null) { SeekMap seekMap = initializationChunk.getSeekMap(); if (seekMap != null) { - representationHolder.segmentIndex = new DashWrappingSegmentIndex((ChunkIndex) seekMap, - initializationChunk.dataSpec.uri.toString()); + representationHolder.segmentIndex = new DashWrappingSegmentIndex((ChunkIndex) seekMap); } } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java index 44da52f52c..c4a4a4446b 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java @@ -23,14 +23,35 @@ import java.util.List; */ public class AdaptationSet { - public static final int UNSET_ID = -1; + /** + * Value of {@link #id} indicating no value is set.= + */ + public static final int ID_UNSET = -1; + /** + * A non-negative identifier for the adaptation set that's unique in the scope of its containing + * period, or {@link #ID_UNSET} if not specified. + */ public final int id; + /** + * The type of the adaptation set. One of the {@link com.google.android.exoplayer2.C} + * {@code TRACK_TYPE_*} constants. + */ public final int type; + /** + * The {@link Representation}s in the adaptation set. + */ public final List representations; + /** + * @param id A non-negative identifier for the adaptation set that's unique in the scope of its + * containing period, or {@link #ID_UNSET} if not specified. + * @param type The type of the adaptation set. One of the {@link com.google.android.exoplayer2.C} + * {@code TRACK_TYPE_*} constants. + * @param representations The {@link Representation}s in the adaptation set. + */ public AdaptationSet(int id, int type, List representations) { this.id = id; this.type = type; 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 7e2ce0de1d..a9dc0a8665 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 @@ -227,7 +227,7 @@ public class DashManifestParser extends DefaultHandler protected AdaptationSet parseAdaptationSet(XmlPullParser xpp, String baseUrl, SegmentBase segmentBase) throws XmlPullParserException, IOException { - int id = parseInt(xpp, "id", AdaptationSet.UNSET_ID); + int id = parseInt(xpp, "id", AdaptationSet.ID_UNSET); int contentType = parseContentType(xpp); String mimeType = xpp.getAttributeValue(null, "mimeType"); @@ -240,7 +240,9 @@ public class DashManifestParser extends DefaultHandler String language = xpp.getAttributeValue(null, "lang"); int accessibilityChannel = Format.NO_VALUE; ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); List representationInfos = new ArrayList<>(); + @C.SelectionFlags int selectionFlags = 0; boolean seenFirstBaseUrl = false; do { @@ -258,32 +260,37 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "ContentComponent")) { language = checkLanguageConsistency(language, xpp.getAttributeValue(null, "lang")); contentType = checkContentTypeConsistency(contentType, parseContentType(xpp)); - } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { - RepresentationInfo representationInfo = parseRepresentation(xpp, baseUrl, mimeType, codecs, - width, height, frameRate, audioChannels, audioSamplingRate, language, - accessibilityChannel, segmentBase); - contentType = checkContentTypeConsistency(contentType, - getContentType(representationInfo.format)); - representationInfos.add(representationInfo); + } else if (XmlPullParserUtil.isStartTag(xpp, "Role")) { + selectionFlags |= parseRole(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { audioChannels = parseAudioChannelConfiguration(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { accessibilityChannel = parseAccessibilityValue(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { + RepresentationInfo representationInfo = parseRepresentation(xpp, baseUrl, mimeType, codecs, + width, height, frameRate, audioChannels, audioSamplingRate, language, + accessibilityChannel, selectionFlags, segmentBase); + contentType = checkContentTypeConsistency(contentType, + getContentType(representationInfo.format)); + representationInfos.add(representationInfo); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBase = parseSegmentTemplate(xpp, (SegmentTemplate) segmentBase); + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseInbandEventStream(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp)) { parseAdaptationSetChild(xpp); } } while (!XmlPullParserUtil.isEndTag(xpp, "AdaptationSet")); + // Build the representations. List representations = new ArrayList<>(representationInfos.size()); for (int i = 0; i < representationInfos.size(); i++) { representations.add(buildRepresentation(representationInfos.get(i), contentId, - drmSchemeDatas)); + drmSchemeDatas, inbandEventStreams)); } return buildAdaptationSet(id, contentType, representations); @@ -311,8 +318,7 @@ public class DashManifestParser extends DefaultHandler return C.TRACK_TYPE_VIDEO; } else if (MimeTypes.isAudio(sampleMimeType)) { return C.TRACK_TYPE_AUDIO; - } else if (mimeTypeIsRawText(sampleMimeType) - || MimeTypes.APPLICATION_RAWCC.equals(format.containerMimeType)) { + } else if (mimeTypeIsRawText(sampleMimeType)) { return C.TRACK_TYPE_TEXT; } return C.TRACK_TYPE_UNKNOWN; @@ -355,6 +361,42 @@ public class DashManifestParser extends DefaultHandler } } + /** + * Parses an InbandEventStream element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return {@link InbandEventStream} parsed from the element. + */ + protected InbandEventStream parseInbandEventStream(XmlPullParser xpp) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", null); + String value = parseString(xpp, "value", null); + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, "InbandEventStream")); + return new InbandEventStream(schemeIdUri, value); + } + + /** + * Parses a Role element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return {@link C.SelectionFlags} parsed from the element. + */ + protected int parseRole(XmlPullParser xpp) throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", null); + String value = parseString(xpp, "value", null); + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, "Role")); + return "urn:mpeg:dash:role:2011".equals(schemeIdUri) && "main".equals(value) + ? C.SELECTION_FLAG_DEFAULT : 0; + } + /** * Parses children of AdaptationSet elements not specifically parsed elsewhere. * @@ -373,8 +415,8 @@ public class DashManifestParser extends DefaultHandler String adaptationSetMimeType, String adaptationSetCodecs, int adaptationSetWidth, int adaptationSetHeight, float adaptationSetFrameRate, int adaptationSetAudioChannels, int adaptationSetAudioSamplingRate, String adaptationSetLanguage, - int adaptationSetAccessibilityChannel, SegmentBase segmentBase) - throws XmlPullParserException, IOException { + int adaptationSetAccessibilityChannel, @C.SelectionFlags int adaptationSetSelectionFlags, + SegmentBase segmentBase) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -386,6 +428,7 @@ public class DashManifestParser extends DefaultHandler int audioChannels = adaptationSetAudioChannels; int audioSamplingRate = parseInt(xpp, "audioSamplingRate", adaptationSetAudioSamplingRate); ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); boolean seenFirstBaseUrl = false; do { @@ -408,52 +451,52 @@ public class DashManifestParser extends DefaultHandler if (contentProtection != null) { drmSchemeDatas.add(contentProtection); } + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseInbandEventStream(xpp)); } } while (!XmlPullParserUtil.isEndTag(xpp, "Representation")); Format format = buildFormat(id, mimeType, width, height, frameRate, audioChannels, audioSamplingRate, bandwidth, adaptationSetLanguage, adaptationSetAccessibilityChannel, - codecs); + adaptationSetSelectionFlags, codecs); segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(); - return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeDatas); + return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeDatas, inbandEventStreams); } protected Format buildFormat(String id, String containerMimeType, int width, int height, float frameRate, int audioChannels, int audioSamplingRate, int bitrate, String language, - int accessiblityChannel, String codecs) { + int accessiblityChannel, @C.SelectionFlags int selectionFlags, String codecs) { String sampleMimeType = getSampleMimeType(containerMimeType, codecs); if (sampleMimeType != null) { if (MimeTypes.isVideo(sampleMimeType)) { return Format.createVideoContainerFormat(id, containerMimeType, sampleMimeType, codecs, - bitrate, width, height, frameRate, null); + bitrate, width, height, frameRate, null, selectionFlags); } else if (MimeTypes.isAudio(sampleMimeType)) { return Format.createAudioContainerFormat(id, containerMimeType, sampleMimeType, codecs, - bitrate, audioChannels, audioSamplingRate, null, 0, language); + bitrate, audioChannels, audioSamplingRate, null, selectionFlags, language); } else if (mimeTypeIsRawText(sampleMimeType)) { return Format.createTextContainerFormat(id, containerMimeType, sampleMimeType, codecs, - 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); + bitrate, selectionFlags, language, accessiblityChannel); } - } else { - return Format.createContainerFormat(id, containerMimeType, codecs, sampleMimeType, bitrate); } + return Format.createContainerFormat(id, containerMimeType, sampleMimeType, codecs, bitrate, + selectionFlags, language); } protected Representation buildRepresentation(RepresentationInfo representationInfo, - String contentId, ArrayList extraDrmSchemeDatas) { + String contentId, ArrayList extraDrmSchemeDatas, + ArrayList extraInbandEventStreams) { Format format = representationInfo.format; ArrayList drmSchemeDatas = representationInfo.drmSchemeDatas; drmSchemeDatas.addAll(extraDrmSchemeDatas); if (!drmSchemeDatas.isEmpty()) { format = format.copyWithDrmInitData(new DrmInitData(drmSchemeDatas)); } + ArrayList inbandEventStremas = representationInfo.inbandEventStreams; + inbandEventStremas.addAll(extraInbandEventStreams); return Representation.newInstance(contentId, Representation.REVISION_ID_DEFAULT, format, - representationInfo.baseUrl, representationInfo.segmentBase); + representationInfo.baseUrl, representationInfo.segmentBase, inbandEventStremas); } // SegmentBase, SegmentList and SegmentTemplate parsing. @@ -664,6 +707,14 @@ public class DashManifestParser extends DefaultHandler return MimeTypes.getAudioMediaMimeType(codecs); } else if (MimeTypes.isVideo(containerMimeType)) { return MimeTypes.getVideoMediaMimeType(codecs); + } else if (mimeTypeIsRawText(containerMimeType)) { + return containerMimeType; + } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { + if ("stpp".equals(codecs)) { + return MimeTypes.APPLICATION_TTML; + } else if ("wvtt".equals(codecs)) { + return MimeTypes.APPLICATION_MP4VTT; + } } else if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { if (codecs != null) { if (codecs.contains("cea708")) { @@ -673,14 +724,6 @@ public class DashManifestParser extends DefaultHandler } } return null; - } else if (mimeTypeIsRawText(containerMimeType)) { - return containerMimeType; - } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { - if ("stpp".equals(codecs)) { - return MimeTypes.APPLICATION_TTML; - } else if ("wvtt".equals(codecs)) { - return MimeTypes.APPLICATION_MP4VTT; - } } return null; } @@ -692,7 +735,11 @@ public class DashManifestParser extends DefaultHandler * @return Whether the mimeType is a text sample mimeType. */ private static boolean mimeTypeIsRawText(String mimeType) { - return MimeTypes.isText(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType); + return MimeTypes.isText(mimeType) + || MimeTypes.APPLICATION_TTML.equals(mimeType) + || MimeTypes.APPLICATION_MP4VTT.equals(mimeType) + || MimeTypes.APPLICATION_CEA708.equals(mimeType) + || MimeTypes.APPLICATION_CEA608.equals(mimeType); } /** @@ -850,13 +897,15 @@ public class DashManifestParser extends DefaultHandler public final String baseUrl; public final SegmentBase segmentBase; public final ArrayList drmSchemeDatas; + public final ArrayList inbandEventStreams; public RepresentationInfo(Format format, String baseUrl, SegmentBase segmentBase, - ArrayList drmSchemeDatas) { + ArrayList drmSchemeDatas, ArrayList inbandEventStreams) { this.format = format; this.baseUrl = baseUrl; this.segmentBase = segmentBase; this.drmSchemeDatas = drmSchemeDatas; + this.inbandEventStreams = inbandEventStreams; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/InbandEventStream.java b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/InbandEventStream.java new file mode 100644 index 0000000000..2f24603598 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/source/dash/manifest/InbandEventStream.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.dash.manifest; + +import com.google.android.exoplayer2.util.Util; + +/** + * Represents a DASH in-band event stream. + */ +public class InbandEventStream { + + public final String schemeIdUri; + public final String value; + + public InbandEventStream(String schemeIdUri, String value) { + this.schemeIdUri = schemeIdUri; + this.value = value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + InbandEventStream other = (InbandEventStream) obj; + return Util.areEqual(schemeIdUri, other.schemeIdUri) && Util.areEqual(value, other.value); + } + + @Override + public int hashCode() { + return 31 * (schemeIdUri != null ? schemeIdUri.hashCode() : 0) + + (value != null ? value.hashCode() : 0); + } + +} 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 f52727c1a8..cdf84f5f71 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 @@ -21,6 +21,8 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.MultiSegmentBase; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; +import java.util.Collections; +import java.util.List; /** * A DASH representation. @@ -60,6 +62,10 @@ public abstract class Representation { * The offset of the presentation timestamps in the media stream relative to media time. */ public final long presentationTimeOffsetUs; + /** + * The {@link InbandEventStream}s in the representation. Never null, but may be empty. + */ + public final List inbandEventStreams; private final RangedUri initializationUri; @@ -78,6 +84,23 @@ public abstract class Representation { return newInstance(contentId, revisionId, format, baseUrl, segmentBase, null); } + /** + * Constructs a new instance. + * + * @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. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. + * @return The constructed instance. + */ + public static Representation newInstance(String contentId, long revisionId, Format format, + String baseUrl, SegmentBase segmentBase, List inbandEventStreams) { + return newInstance(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams, + null); + } + /** * Constructs a new instance. * @@ -86,18 +109,20 @@ public abstract class Representation { * @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 inbandEventStreams The {@link InbandEventStream}s in the representation. May be 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, - String baseUrl, SegmentBase segmentBase, String customCacheKey) { + String baseUrl, SegmentBase segmentBase, List inbandEventStreams, + String customCacheKey) { if (segmentBase instanceof SingleSegmentBase) { return new SingleSegmentRepresentation(contentId, revisionId, format, baseUrl, - (SingleSegmentBase) segmentBase, customCacheKey, C.LENGTH_UNSET); + (SingleSegmentBase) segmentBase, inbandEventStreams, customCacheKey, C.LENGTH_UNSET); } else if (segmentBase instanceof MultiSegmentBase) { return new MultiSegmentRepresentation(contentId, revisionId, format, baseUrl, - (MultiSegmentBase) segmentBase); + (MultiSegmentBase) segmentBase, inbandEventStreams); } else { throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); @@ -105,11 +130,14 @@ public abstract class Representation { } private Representation(String contentId, long revisionId, Format format, String baseUrl, - SegmentBase segmentBase) { + SegmentBase segmentBase, List inbandEventStreams) { this.contentId = contentId; this.revisionId = revisionId; this.format = format; this.baseUrl = baseUrl; + this.inbandEventStreams = inbandEventStreams == null + ? Collections.emptyList() + : Collections.unmodifiableList(inbandEventStreams); initializationUri = segmentBase.getInitialization(this); presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs(); } @@ -167,18 +195,20 @@ public abstract class Representation { * @param initializationEnd The offset of the last byte of initialization data. * @param indexStart The offset of the first byte of index data. * @param indexEnd The offset of the last byte of index data. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. * @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 static SingleSegmentRepresentation newInstance(String contentId, long revisionId, Format format, String uri, long initializationStart, long initializationEnd, - long indexStart, long indexEnd, String customCacheKey, long contentLength) { + long indexStart, long indexEnd, List inbandEventStreams, + String customCacheKey, long contentLength) { RangedUri rangedUri = new RangedUri(null, initializationStart, initializationEnd - initializationStart + 1); SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, indexStart, indexEnd - indexStart + 1); return new SingleSegmentRepresentation(contentId, revisionId, - format, uri, segmentBase, customCacheKey, contentLength); + format, uri, segmentBase, inbandEventStreams, customCacheKey, contentLength); } /** @@ -187,12 +217,14 @@ public abstract class Representation { * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. * @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, - String baseUrl, SingleSegmentBase segmentBase, String customCacheKey, long contentLength) { - super(contentId, revisionId, format, baseUrl, segmentBase); + String baseUrl, SingleSegmentBase segmentBase, List inbandEventStreams, + String customCacheKey, long contentLength) { + super(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.uri = Uri.parse(baseUrl); this.indexUri = segmentBase.getIndex(); this.cacheKey = customCacheKey != null ? customCacheKey @@ -235,10 +267,11 @@ public abstract class Representation { * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The {@link InbandEventStream}s in the representation. May be null. */ public MultiSegmentRepresentation(String contentId, long revisionId, Format format, - String baseUrl, MultiSegmentBase segmentBase) { - super(contentId, revisionId, format, baseUrl, segmentBase); + String baseUrl, MultiSegmentBase segmentBase, List inbandEventStreams) { + super(contentId, revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.segmentBase = segmentBase; } 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 ef319d508d..70a65e932a 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 @@ -143,7 +143,7 @@ public abstract class SegmentBase { } else { // The high index cannot be unbounded. Identify the segment using binary search. while (lowIndex <= highIndex) { - int midIndex = (lowIndex + highIndex) / 2; + int midIndex = lowIndex + (highIndex - lowIndex) / 2; long midTimeUs = getSegmentTimeUs(midIndex); if (midTimeUs < timeUs) { lowIndex = midIndex + 1; 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 b953fcf79c..edd3c735c1 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 @@ -270,8 +270,10 @@ import java.util.Locale; // Compute start time of the next chunk. long startTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs; + int discontinuitySequence = mediaPlaylist.discontinuitySequence + + segment.relativeDiscontinuitySequence; TimestampAdjuster timestampAdjuster = timestampAdjusterProvider.getAdjuster( - segment.discontinuitySequenceNumber, startTimeUs); + discontinuitySequence, startTimeUs); // Configure the data source and spec for the chunk. Uri chunkUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, segment.url); @@ -279,9 +281,8 @@ import java.util.Locale; null); out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex], trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence, - segment.discontinuitySequenceNumber, isTimestampMaster, timestampAdjuster, previous, - encryptionKey, encryptionIv); + startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence, discontinuitySequence, + isTimestampMaster, timestampAdjuster, previous, encryptionKey, encryptionIv); } /** 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 f9dba14e0e..0c411854d5 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 @@ -79,8 +79,10 @@ import java.util.concurrent.atomic.AtomicInteger; private final boolean isEncrypted; private final boolean isMasterTimestampSource; private final TimestampAdjuster timestampAdjuster; - private final HlsMediaChunk previousChunk; private final String lastPathSegment; + private final Extractor previousExtractor; + private final boolean shouldSpliceIn; + private final boolean needNewExtractor; private final boolean isPackedAudio; private final Id3Decoder id3Decoder; @@ -123,7 +125,6 @@ import java.util.concurrent.atomic.AtomicInteger; this.isMasterTimestampSource = isMasterTimestampSource; this.timestampAdjuster = timestampAdjuster; this.discontinuitySequenceNumber = discontinuitySequenceNumber; - this.previousChunk = previousChunk; // Note: this.dataSource and dataSource may be different. this.isEncrypted = this.dataSource instanceof Aes128DataSource; lastPathSegment = dataSpec.uri.getLastPathSegment(); @@ -131,13 +132,19 @@ import java.util.concurrent.atomic.AtomicInteger; || 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); + if (previousChunk != null) { + id3Decoder = previousChunk.id3Decoder; + id3Data = previousChunk.id3Data; + previousExtractor = previousChunk.extractor; + shouldSpliceIn = previousChunk.hlsUrl != hlsUrl; + needNewExtractor = previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber + || shouldSpliceIn; } else { - id3Decoder = null; - id3Data = null; + id3Decoder = isPackedAudio ? new Id3Decoder() : null; + id3Data = isPackedAudio ? new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH) : null; + previousExtractor = null; + shouldSpliceIn = false; + needNewExtractor = true; } initDataSource = dataSource; uid = UID_SOURCE.getAndIncrement(); @@ -151,7 +158,7 @@ import java.util.concurrent.atomic.AtomicInteger; */ public void init(HlsSampleStreamWrapper output) { extractorOutput = output; - output.init(uid, previousChunk != null && previousChunk.hlsUrl != hlsUrl); + output.init(uid, shouldSpliceIn); } @Override @@ -191,8 +198,8 @@ import java.util.concurrent.atomic.AtomicInteger; // Internal loading methods. private void maybeLoadInitData() throws IOException, InterruptedException { - if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted - || initDataSpec == null) { + if (previousExtractor == extractor || initLoadCompleted || initDataSpec == null) { + // According to spec, for packed audio, initDataSpec is expected to be null. return; } DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); @@ -325,9 +332,6 @@ import java.util.concurrent.atomic.AtomicInteger; 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)) { @@ -335,7 +339,7 @@ import java.util.concurrent.atomic.AtomicInteger; } else if (!needNewExtractor) { // Only reuse TS and fMP4 extractors. usingNewExtractor = false; - extractor = previousChunk.extractor; + extractor = previousExtractor; } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION)) { extractor = new FragmentedMp4Extractor(0, timestampAdjuster); } else { 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 869efa6cdc..10e12f0ec6 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 @@ -94,24 +94,33 @@ public final class HlsMediaSource implements MediaSource, @Override public void releaseSource() { - playlistTracker.release(); - playlistTracker = null; + if (playlistTracker != null) { + playlistTracker.release(); + playlistTracker = null; + } sourceListener = null; } @Override public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { SinglePeriodTimeline timeline; + long windowDefaultStartPositionUs = playlist.startOffsetUs; if (playlistTracker.isLive()) { - // TODO: fix windowPositionInPeriodUs when playlist is empty. + long periodDurationUs = playlist.hasEndTag ? (playlist.startTimeUs + playlist.durationUs) + : C.TIME_UNSET; 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, + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = segments.isEmpty() ? 0 + : segments.get(Math.max(0, segments.size() - 3)).relativeStartTimeUs; + } + timeline = new SinglePeriodTimeline(periodDurationUs, playlist.durationUs, playlist.startTimeUs, windowDefaultStartPositionUs, true, !playlist.hasEndTag); } else /* not live */ { + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = 0; + } timeline = new SinglePeriodTimeline(playlist.startTimeUs + playlist.durationUs, - playlist.durationUs, playlist.startTimeUs, 0, true, false); + playlist.durationUs, playlist.startTimeUs, windowDefaultStartPositionUs, true, false); } sourceListener.onSourceInfoRefreshed(timeline, playlist); } diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStream.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStream.java index c63cf3e5a4..04fe8a093c 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStream.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStream.java @@ -26,7 +26,7 @@ import java.io.IOException; /* package */ final class HlsSampleStream implements SampleStream { public final int group; - + private final HlsSampleStreamWrapper sampleStreamWrapper; public HlsSampleStream(HlsSampleStreamWrapper sampleStreamWrapper, int group) { 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 4aaec59f7d..b7426fd03d 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 @@ -40,7 +40,7 @@ public final class HlsMasterPlaylist extends HlsPlaylist { public static HlsUrl createMediaPlaylistHlsUrl(String baseUri) { Format format = Format.createContainerFormat("0", MimeTypes.APPLICATION_M3U8, null, null, - Format.NO_VALUE); + Format.NO_VALUE, 0, null); return new HlsUrl(null, baseUri, format, null, 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 fc70ec6de1..0b61b9781e 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 @@ -31,7 +31,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { public final String url; public final long durationUs; - public final int discontinuitySequenceNumber; + public final int relativeDiscontinuitySequence; public final long relativeStartTimeUs; public final boolean isEncrypted; public final String encryptionKeyUri; @@ -43,12 +43,12 @@ public final class HlsMediaPlaylist extends HlsPlaylist { this(uri, 0, -1, C.TIME_UNSET, false, null, null, byterangeOffset, byterangeLength); } - public Segment(String uri, long durationUs, int discontinuitySequenceNumber, + public Segment(String uri, long durationUs, int relativeDiscontinuitySequence, long relativeStartTimeUs, boolean isEncrypted, String encryptionKeyUri, String encryptionIV, long byterangeOffset, long byterangeLength) { this.url = uri; this.durationUs = durationUs; - this.discontinuitySequenceNumber = discontinuitySequenceNumber; + this.relativeDiscontinuitySequence = relativeDiscontinuitySequence; this.relativeStartTimeUs = relativeStartTimeUs; this.isEncrypted = isEncrypted; this.encryptionKeyUri = encryptionKeyUri; @@ -65,7 +65,10 @@ public final class HlsMediaPlaylist extends HlsPlaylist { } + public final long startOffsetUs; public final long startTimeUs; + public final boolean hasDiscontinuitySequence; + public final int discontinuitySequence; public final int mediaSequence; public final int version; public final long targetDurationUs; @@ -75,11 +78,14 @@ public final class HlsMediaPlaylist extends HlsPlaylist { public final List segments; public final long durationUs; - public HlsMediaPlaylist(String baseUri, long startTimeUs, int mediaSequence, - int version, long targetDurationUs, boolean hasEndTag, boolean hasProgramDateTime, + public HlsMediaPlaylist(String baseUri, long startOffsetUs, long startTimeUs, + boolean hasDiscontinuitySequence, int discontinuitySequence, int mediaSequence, int version, + long targetDurationUs, boolean hasEndTag, boolean hasProgramDateTime, Segment initializationSegment, List segments) { super(baseUri, HlsPlaylist.TYPE_MEDIA); this.startTimeUs = startTimeUs; + this.hasDiscontinuitySequence = hasDiscontinuitySequence; + this.discontinuitySequence = discontinuitySequence; this.mediaSequence = mediaSequence; this.version = version; this.targetDurationUs = targetDurationUs; @@ -87,28 +93,68 @@ public final class HlsMediaPlaylist extends HlsPlaylist { this.hasProgramDateTime = hasProgramDateTime; this.initializationSegment = initializationSegment; this.segments = Collections.unmodifiableList(segments); - if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); durationUs = last.relativeStartTimeUs + last.durationUs; } else { durationUs = 0; } + this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET + : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs; } + /** + * Returns whether this playlist is newer than {@code other}. + * + * @param other The playlist to compare. + * @return Whether this playlist is newer than {@code other}. + */ public boolean isNewerThan(HlsMediaPlaylist other) { - return other == null || mediaSequence > other.mediaSequence - || (mediaSequence == other.mediaSequence && segments.size() > other.segments.size()) - || (hasEndTag && !other.hasEndTag); + if (other == null || mediaSequence > other.mediaSequence) { + return true; + } + if (mediaSequence < other.mediaSequence) { + return false; + } + // The media sequences are equal. + int segmentCount = segments.size(); + int otherSegmentCount = other.segments.size(); + return segmentCount > otherSegmentCount + || (segmentCount == otherSegmentCount && 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); + /** + * Returns a playlist identical to this one except for the start time, the discontinuity sequence + * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values, + * {@code hasDiscontinuitySequence} is set to true. + * + * @param startTimeUs The start time for the returned playlist. + * @param discontinuitySequence The discontinuity sequence for the returned playlist. + * @return The playlist. + */ + public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { + return new HlsMediaPlaylist(baseUri, startOffsetUs, startTimeUs, true, discontinuitySequence, + mediaSequence, version, targetDurationUs, hasEndTag, hasProgramDateTime, + initializationSegment, segments); + } + + /** + * Returns a playlist identical to this one except that an end tag is added. If an end tag is + * already present then the playlist will return itself. + * + * @return The playlist. + */ + public HlsMediaPlaylist copyWithEndTag() { + if (this.hasEndTag) { + return this; + } + return new HlsMediaPlaylist(baseUri, startOffsetUs, startTimeUs, hasDiscontinuitySequence, + discontinuitySequence, mediaSequence, version, targetDurationUs, true, 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 1932caccf7..c349bbee05 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 @@ -39,16 +39,33 @@ import java.util.regex.Pattern; */ public final class HlsPlaylistParser implements ParsingLoadable.Parser { + /** + * Thrown if the input does not start with an HLS playlist header. + */ + public static final class UnrecognizedInputFormatException extends ParserException { + + public final Uri inputUri; + + public UnrecognizedInputFormatException(Uri inputUri) { + super("Input does not start with the #EXTM3U header. Uri: " + inputUri); + this.inputUri = inputUri; + } + + } + + private static final String PLAYLIST_HEADER = "#EXTM3U"; + private static final String TAG_VERSION = "#EXT-X-VERSION"; private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF"; private static final String TAG_MEDIA = "#EXT-X-MEDIA"; + private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION"; private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY"; private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE"; private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME"; private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP"; private static final String TAG_MEDIA_DURATION = "#EXTINF"; private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE"; - private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION"; + private static final String TAG_START = "#EXT-X-START"; private static final String TAG_ENDLIST = "#EXT-X-ENDLIST"; private static final String TAG_KEY = "#EXT-X-KEY"; private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE"; @@ -74,6 +91,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser extraLines = new LinkedList<>(); String line; try { + if (!checkPlaylistHeader(reader)) { + throw new UnrecognizedInputFormatException(uri); + } while ((line = reader.readLine()) != null) { line = line.trim(); if (line.isEmpty()) { @@ -119,11 +140,40 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variants = new ArrayList<>(); @@ -190,7 +240,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser segments = new ArrayList<>(); long segmentDurationUs = 0; - int discontinuitySequenceNumber = 0; + boolean hasDiscontinuitySequence = false; + int playlistDiscontinuitySequence = 0; + int relativeDiscontinuitySequence = 0; long playlistStartTimeUs = 0; long segmentStartTimeUs = 0; long segmentByteRangeOffset = 0; @@ -229,7 +282,9 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser 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); + return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null; } /** @@ -460,15 +486,15 @@ public final class HlsPlaylistTracker implements Loader.Callback { List codecSpecificData = buildCodecSpecificData( parser.getAttributeValue(null, KEY_CODEC_PRIVATE_DATA)); format = Format.createVideoContainerFormat(id, MimeTypes.VIDEO_MP4, sampleMimeType, null, - bitrate, width, height, Format.NO_VALUE, codecSpecificData); + bitrate, width, height, Format.NO_VALUE, codecSpecificData, 0); } else if (type == C.TRACK_TYPE_AUDIO) { sampleMimeType = sampleMimeType == null ? MimeTypes.AUDIO_AAC : sampleMimeType; int channels = parseRequiredInt(parser, KEY_CHANNELS); @@ -644,8 +644,8 @@ public class SsManifestParser implements ParsingLoadable.Parser { format = Format.createTextContainerFormat(id, MimeTypes.APPLICATION_MP4, sampleMimeType, null, bitrate, 0, language); } else { - format = Format.createContainerFormat(id, MimeTypes.APPLICATION_MP4, null, sampleMimeType, - bitrate); + format = Format.createContainerFormat(id, MimeTypes.APPLICATION_MP4, sampleMimeType, null, + bitrate, 0, null); } } 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 8a8a37d5ed..ee8a430a71 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 @@ -67,6 +67,7 @@ public interface SubtitleDecoderFactory { *

  • AAS/SSA ({@link SSADecoder})
  • *
  • TX3G ({@link Tx3gDecoder})
  • *
  • Cea608 ({@link Cea608Decoder})
  • + *
  • Cea708 ({@link Cea708Decoder})
  • * */ SubtitleDecoderFactory DEFAULT = new SubtitleDecoderFactory() { @@ -83,6 +84,7 @@ public interface SubtitleDecoderFactory { if (clazz == null) { throw new IllegalArgumentException("Attempted to create decoder for unsupported format"); } +<<<<<<< HEAD if(clazz == SSADecoder.class) { byte[] header = format.initializationData.get(1); String dlgfmt = new String(format.initializationData.get(0), "UTF-8"); @@ -94,6 +96,16 @@ public interface SubtitleDecoderFactory { .newInstance(format.sampleMimeType, format.accessibilityChannel); } else { +======= + if (format.sampleMimeType.equals(MimeTypes.APPLICATION_CEA608) + || format.sampleMimeType.equals(MimeTypes.APPLICATION_MP4CEA608)) { + return clazz.asSubclass(SubtitleDecoder.class).getConstructor(String.class, Integer.TYPE) + .newInstance(format.sampleMimeType, format.accessibilityChannel); + } else if (format.sampleMimeType.equals(MimeTypes.APPLICATION_CEA708)) { + return clazz.asSubclass(SubtitleDecoder.class).getConstructor(Integer.TYPE) + .newInstance(format.accessibilityChannel); + } else { +>>>>>>> upstream/dev-v2 return clazz.asSubclass(SubtitleDecoder.class).getConstructor().newInstance(); } } catch (Exception e) { @@ -122,6 +134,8 @@ public interface SubtitleDecoderFactory { case MimeTypes.APPLICATION_CEA608: case MimeTypes.APPLICATION_MP4CEA608: return Class.forName("com.google.android.exoplayer2.text.cea.Cea608Decoder"); + case MimeTypes.APPLICATION_CEA708: + return Class.forName("com.google.android.exoplayer2.text.cea.Cea708Decoder"); default: return null; } diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java new file mode 100644 index 0000000000..e63d1d4118 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Cue.java @@ -0,0 +1,67 @@ +/* + * 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.text.cea; + +import android.text.Layout.Alignment; +import com.google.android.exoplayer2.text.Cue; + +/** + * A {@link Cue} for CEA-708. + */ +/* package */ final class Cea708Cue extends Cue implements Comparable { + + /** + * An unset priority. + */ + public static final int PRIORITY_UNSET = -1; + + /** + * The priority of the cue box. + */ + public final int priority; + + /** + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + * @param windowColorSet See {@link #windowColorSet}. + * @param windowColor See {@link #windowColor}. + * @param priority See (@link #priority}. + */ + public Cea708Cue(CharSequence text, Alignment textAlignment, float line, @LineType int lineType, + @AnchorType int lineAnchor, float position, @AnchorType int positionAnchor, float size, + boolean windowColorSet, int windowColor, int priority) { + super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, size, + windowColorSet, windowColor); + this.priority = priority; + } + + @Override + public int compareTo(Cea708Cue other) { + if (other.priority < priority) { + return -1; + } else if (other.priority > priority) { + return 1; + } + return 0; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java new file mode 100644 index 0000000000..5ca5ce1270 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java @@ -0,0 +1,1225 @@ +/* + * 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.text.cea; + +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.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.Cue.AnchorType; +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.Assertions; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * A {@link SubtitleDecoder} for CEA-708 (also known as "EIA-708"). + * + *

    This implementation does not provide full compatibility with the CEA-708 specification. Note + * that only the default pen/text and window/cue colors (i.e. text with + * {@link CueBuilder#COLOR_SOLID_WHITE} foreground and {@link CueBuilder#COLOR_SOLID_BLACK} + * background, and cues with {@link CueBuilder#COLOR_SOLID_BLACK} fill) will be overridden with + * device accessibility settings; all others will use the colors and opacity specified by the + * caption data. + */ +public final class Cea708Decoder extends CeaDecoder { + + private static final String TAG = "Cea708Decoder"; + + private static final int NUM_WINDOWS = 8; + + private static final int DTVCC_PACKET_DATA = 0x02; + private static final int DTVCC_PACKET_START = 0x03; + private static final int CC_VALID_FLAG = 0x04; + + // Base Commands + private static final int GROUP_C0_END = 0x1F; // Miscellaneous Control Codes + private static final int GROUP_G0_END = 0x7F; // ASCII Printable Characters + private static final int GROUP_C1_END = 0x9F; // Captioning Command Control Codes + private static final int GROUP_G1_END = 0xFF; // ISO 8859-1 LATIN-1 Character Set + + // Extended Commands + private static final int GROUP_C2_END = 0x1F; // Extended Control Code Set 1 + private static final int GROUP_G2_END = 0x7F; // Extended Miscellaneous Characters + private static final int GROUP_C3_END = 0x9F; // Extended Control Code Set 2 + private static final int GROUP_G3_END = 0xFF; // Future Expansion + + // Group C0 Commands + private static final int COMMAND_NUL = 0x00; // Nul + private static final int COMMAND_ETX = 0x03; // EndOfText + private static final int COMMAND_BS = 0x08; // Backspace + private static final int COMMAND_FF = 0x0C; // FormFeed (Flush) + private static final int COMMAND_CR = 0x0D; // CarriageReturn + private static final int COMMAND_HCR = 0x0E; // ClearLine + private static final int COMMAND_EXT1 = 0x10; // Extended Control Code Flag + private static final int COMMAND_EXT1_START = 0x11; + private static final int COMMAND_EXT1_END = 0x17; + private static final int COMMAND_P16_START = 0x18; + private static final int COMMAND_P16_END = 0x1F; + + // Group C1 Commands + private static final int COMMAND_CW0 = 0x80; // SetCurrentWindow to 0 + private static final int COMMAND_CW1 = 0x81; // SetCurrentWindow to 1 + private static final int COMMAND_CW2 = 0x82; // SetCurrentWindow to 2 + private static final int COMMAND_CW3 = 0x83; // SetCurrentWindow to 3 + private static final int COMMAND_CW4 = 0x84; // SetCurrentWindow to 4 + private static final int COMMAND_CW5 = 0x85; // SetCurrentWindow to 5 + private static final int COMMAND_CW6 = 0x86; // SetCurrentWindow to 6 + private static final int COMMAND_CW7 = 0x87; // SetCurrentWindow to 7 + private static final int COMMAND_CLW = 0x88; // ClearWindows (+1 byte) + private static final int COMMAND_DSW = 0x89; // DisplayWindows (+1 byte) + private static final int COMMAND_HDW = 0x8A; // HideWindows (+1 byte) + private static final int COMMAND_TGW = 0x8B; // ToggleWindows (+1 byte) + private static final int COMMAND_DLW = 0x8C; // DeleteWindows (+1 byte) + private static final int COMMAND_DLY = 0x8D; // Delay (+1 byte) + private static final int COMMAND_DLC = 0x8E; // DelayCancel + private static final int COMMAND_RST = 0x8F; // Reset + private static final int COMMAND_SPA = 0x90; // SetPenAttributes (+2 bytes) + private static final int COMMAND_SPC = 0x91; // SetPenColor (+3 bytes) + private static final int COMMAND_SPL = 0x92; // SetPenLocation (+2 bytes) + private static final int COMMAND_SWA = 0x97; // SetWindowAttributes (+4 bytes) + private static final int COMMAND_DF0 = 0x98; // DefineWindow 0 (+6 bytes) + private static final int COMMAND_DF1 = 0x99; // DefineWindow 1 (+6 bytes) + private static final int COMMAND_DF2 = 0x9A; // DefineWindow 2 (+6 bytes) + private static final int COMMAND_DF3 = 0x9B; // DefineWindow 3 (+6 bytes) + private static final int COMMAND_DS4 = 0x9C; // DefineWindow 4 (+6 bytes) + private static final int COMMAND_DF5 = 0x9D; // DefineWindow 5 (+6 bytes) + private static final int COMMAND_DF6 = 0x9E; // DefineWindow 6 (+6 bytes) + private static final int COMMAND_DF7 = 0x9F; // DefineWindow 7 (+6 bytes) + + // G0 Table Special Chars + private static final int CHARACTER_MN = 0x7F; // MusicNote + + // G2 Table Special Chars + private static final int CHARACTER_TSP = 0x20; + private static final int CHARACTER_NBTSP = 0x21; + private static final int CHARACTER_ELLIPSIS = 0x25; + private static final int CHARACTER_BIG_CARONS = 0x2A; + private static final int CHARACTER_BIG_OE = 0x2C; + private static final int CHARACTER_SOLID_BLOCK = 0x30; + private static final int CHARACTER_OPEN_SINGLE_QUOTE = 0x31; + private static final int CHARACTER_CLOSE_SINGLE_QUOTE = 0x32; + private static final int CHARACTER_OPEN_DOUBLE_QUOTE = 0x33; + private static final int CHARACTER_CLOSE_DOUBLE_QUOTE = 0x34; + private static final int CHARACTER_BOLD_BULLET = 0x35; + private static final int CHARACTER_TM = 0x39; + private static final int CHARACTER_SMALL_CARONS = 0x3A; + private static final int CHARACTER_SMALL_OE = 0x3C; + private static final int CHARACTER_SM = 0x3D; + private static final int CHARACTER_DIAERESIS_Y = 0x3F; + private static final int CHARACTER_ONE_EIGHTH = 0x76; + private static final int CHARACTER_THREE_EIGHTHS = 0x77; + private static final int CHARACTER_FIVE_EIGHTHS = 0x78; + private static final int CHARACTER_SEVEN_EIGHTHS = 0x79; + private static final int CHARACTER_VERTICAL_BORDER = 0x7A; + private static final int CHARACTER_UPPER_RIGHT_BORDER = 0x7B; + private static final int CHARACTER_LOWER_LEFT_BORDER = 0x7C; + private static final int CHARACTER_HORIZONTAL_BORDER = 0x7D; + private static final int CHARACTER_LOWER_RIGHT_BORDER = 0x7E; + private static final int CHARACTER_UPPER_LEFT_BORDER = 0x7F; + + private final ParsableByteArray ccData; + private final ParsableBitArray serviceBlockPacket; + + private final int selectedServiceNumber; + private final CueBuilder[] cueBuilders; + + private CueBuilder currentCueBuilder; + private List cues; + private List lastCues; + + private DtvCcPacket currentDtvCcPacket; + private int currentWindow; + + public Cea708Decoder(int accessibilityChannel) { + ccData = new ParsableByteArray(); + serviceBlockPacket = new ParsableBitArray(); + selectedServiceNumber = (accessibilityChannel == Format.NO_VALUE) ? 1 : accessibilityChannel; + + cueBuilders = new CueBuilder[NUM_WINDOWS]; + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i] = new CueBuilder(); + } + + currentCueBuilder = cueBuilders[0]; + resetCueBuilders(); + } + + @Override + public String getName() { + return "Cea708Decoder"; + } + + @Override + public void flush() { + super.flush(); + cues = null; + lastCues = null; + currentWindow = 0; + currentCueBuilder = cueBuilders[currentWindow]; + resetCueBuilders(); + currentDtvCcPacket = null; + } + + @Override + protected boolean isNewSubtitleDataAvailable() { + return cues != lastCues; + } + + @Override + protected Subtitle createSubtitle() { + lastCues = cues; + return new CeaSubtitle(cues); + } + + @Override + protected void decode(SubtitleInputBuffer inputBuffer) { + ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit()); + while (ccData.bytesLeft() >= 3) { + int ccTypeAndValid = (ccData.readUnsignedByte() & 0x07); + + int ccType = ccTypeAndValid & (DTVCC_PACKET_DATA | DTVCC_PACKET_START); + boolean ccValid = (ccTypeAndValid & CC_VALID_FLAG) == CC_VALID_FLAG; + byte ccData1 = (byte) ccData.readUnsignedByte(); + byte ccData2 = (byte) ccData.readUnsignedByte(); + + // Ignore any non-CEA-708 data + if (ccType != DTVCC_PACKET_DATA && ccType != DTVCC_PACKET_START) { + continue; + } + + if (!ccValid) { + finalizeCurrentPacket(); + continue; + } + + if (ccType == DTVCC_PACKET_START) { + finalizeCurrentPacket(); + + int sequenceNumber = (ccData1 & 0xC0) >> 6; // first 2 bits + int packetSize = ccData1 & 0x3F; // last 6 bits + if (packetSize == 0) { + packetSize = 64; + } + + currentDtvCcPacket = new DtvCcPacket(sequenceNumber, packetSize); + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } else { + // The only remaining valid packet type is DTVCC_PACKET_DATA + Assertions.checkArgument(ccType == DTVCC_PACKET_DATA); + + if (currentDtvCcPacket == null) { + Log.e(TAG, "Encountered DTVCC_PACKET_DATA before DTVCC_PACKET_START"); + continue; + } + + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData1; + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } + + if (currentDtvCcPacket.currentIndex == (currentDtvCcPacket.packetSize * 2 - 1)) { + finalizeCurrentPacket(); + } + } + } + + private void finalizeCurrentPacket() { + if (currentDtvCcPacket == null) { + // No packet to finalize; + return; + } + + processCurrentPacket(); + currentDtvCcPacket = null; + } + + private void processCurrentPacket() { + if (currentDtvCcPacket.currentIndex != (currentDtvCcPacket.packetSize * 2 - 1)) { + Log.w(TAG, "DtvCcPacket ended prematurely; size is " + (currentDtvCcPacket.packetSize * 2 - 1) + + ", but current index is " + currentDtvCcPacket.currentIndex + " (sequence number " + + currentDtvCcPacket.sequenceNumber + ")"); + } + + serviceBlockPacket.reset(currentDtvCcPacket.packetData, currentDtvCcPacket.currentIndex); + + int serviceNumber = serviceBlockPacket.readBits(3); + int blockSize = serviceBlockPacket.readBits(5); + if (serviceNumber == 7) { + // extended service numbers + serviceBlockPacket.skipBits(2); + serviceNumber += serviceBlockPacket.readBits(6); + } + + // Ignore packets in which blockSize is 0 + if (blockSize == 0) { + if (serviceNumber != 0) { + Log.w(TAG, "serviceNumber is non-zero (" + serviceNumber + ") when blockSize is 0"); + } + return; + } + + if (serviceNumber != selectedServiceNumber) { + return; + } + + while (serviceBlockPacket.bitsLeft() > 0) { + int command = serviceBlockPacket.readBits(8); + if (command != COMMAND_EXT1) { + if (command <= GROUP_C0_END) { + handleC0Command(command); + } else if (command <= GROUP_G0_END) { + handleG0Character(command); + } else if (command <= GROUP_C1_END) { + handleC1Command(command); + // Cues are always updated after a C1 command + cues = getDisplayCues(); + } else if (command <= GROUP_G1_END) { + handleG1Character(command); + } else { + Log.w(TAG, "Invalid base command: " + command); + } + } else { + // Read the extended command + command = serviceBlockPacket.readBits(8); + if (command <= GROUP_C2_END) { + handleC2Command(command); + } else if (command <= GROUP_G2_END) { + handleG2Character(command); + } else if (command <= GROUP_C3_END) { + handleC3Command(command); + } else if (command <= GROUP_G3_END) { + handleG3Character(command); + } else { + Log.w(TAG, "Invalid extended command: " + command); + } + } + } + } + + private void handleC0Command(int command) { + switch (command) { + case COMMAND_NUL: + // Do nothing. + break; + case COMMAND_ETX: + cues = getDisplayCues(); + break; + case COMMAND_BS: + currentCueBuilder.backspace(); + break; + case COMMAND_FF: + resetCueBuilders(); + break; + case COMMAND_CR: + currentCueBuilder.append('\n'); + break; + case COMMAND_HCR: + // TODO: Add support for this command. + break; + default: + if (command >= COMMAND_EXT1_START && command <= COMMAND_EXT1_END) { + Log.w(TAG, "Currently unsupported COMMAND_EXT1 Command: " + command); + serviceBlockPacket.skipBits(8); + } else if (command >= COMMAND_P16_START && command <= COMMAND_P16_END) { + Log.w(TAG, "Currently unsupported COMMAND_P16 Command: " + command); + serviceBlockPacket.skipBits(16); + } else { + Log.w(TAG, "Invalid C0 command: " + command); + } + } + } + + private void handleC1Command(int command) { + int window; + switch (command) { + case COMMAND_CW0: + case COMMAND_CW1: + case COMMAND_CW2: + case COMMAND_CW3: + case COMMAND_CW4: + case COMMAND_CW5: + case COMMAND_CW6: + case COMMAND_CW7: + window = (command - COMMAND_CW0); + if (currentWindow != window) { + currentWindow = window; + currentCueBuilder = cueBuilders[window]; + } + break; + case COMMAND_CLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].clear(); + } + } + break; + case COMMAND_DSW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(true); + } + } + break; + case COMMAND_HDW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(false); + } + } + break; + case COMMAND_TGW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + CueBuilder cueBuilder = cueBuilders[NUM_WINDOWS - i]; + cueBuilder.setVisibility(!cueBuilder.isVisible()); + } + } + break; + case COMMAND_DLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].reset(); + } + } + break; + case COMMAND_DLY: + // TODO: Add support for delay commands. + serviceBlockPacket.skipBits(8); + break; + case COMMAND_DLC: + // TODO: Add support for delay commands. + break; + case COMMAND_RST: + resetCueBuilders(); + break; + case COMMAND_SPA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenAttributes(); + } + break; + case COMMAND_SPC: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(24); + } else { + handleSetPenColor(); + } + break; + case COMMAND_SPL: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenLocation(); + } + break; + case COMMAND_SWA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(32); + } else { + handleSetWindowAttributes(); + } + break; + case COMMAND_DF0: + case COMMAND_DF1: + case COMMAND_DF2: + case COMMAND_DF3: + case COMMAND_DS4: + case COMMAND_DF5: + case COMMAND_DF6: + case COMMAND_DF7: + window = (command - COMMAND_DF0); + handleDefineWindow(window); + break; + default: + Log.w(TAG, "Invalid C1 command: " + command); + } + } + + private void handleC2Command(int command) { + // C2 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x0F) { + // Do nothing. + } else if (command <= 0x0F) { + serviceBlockPacket.skipBits(8); + } else if (command <= 0x17) { + serviceBlockPacket.skipBits(16); + } else if (command <= 0x1F) { + serviceBlockPacket.skipBits(24); + } + } + + private void handleC3Command(int command) { + // C3 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x87) { + serviceBlockPacket.skipBits(32); + } else if (command <= 0x8F) { + serviceBlockPacket.skipBits(40); + } else if (command <= 0x9F) { + // 90-9F are variable length codes; the first byte defines the header with the first + // 2 bits specifying the type and the last 6 bits specifying the remaining length of the + // command in bytes + serviceBlockPacket.skipBits(2); + int length = serviceBlockPacket.readBits(6); + serviceBlockPacket.skipBits(8 * length); + } + } + + private void handleG0Character(int characterCode) { + if (characterCode == CHARACTER_MN) { + currentCueBuilder.append('\u266B'); + } else { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + } + + private void handleG1Character(int characterCode) { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + + private void handleG2Character(int characterCode) { + switch (characterCode) { + case CHARACTER_TSP: + currentCueBuilder.append('\u0020'); + break; + case CHARACTER_NBTSP: + currentCueBuilder.append('\u00A0'); + break; + case CHARACTER_ELLIPSIS: + currentCueBuilder.append('\u2026'); + break; + case CHARACTER_BIG_CARONS: + currentCueBuilder.append('\u0160'); + break; + case CHARACTER_BIG_OE: + currentCueBuilder.append('\u0152'); + break; + case CHARACTER_SOLID_BLOCK: + currentCueBuilder.append('\u2588'); + break; + case CHARACTER_OPEN_SINGLE_QUOTE: + currentCueBuilder.append('\u2018'); + break; + case CHARACTER_CLOSE_SINGLE_QUOTE: + currentCueBuilder.append('\u2019'); + break; + case CHARACTER_OPEN_DOUBLE_QUOTE: + currentCueBuilder.append('\u201C'); + break; + case CHARACTER_CLOSE_DOUBLE_QUOTE: + currentCueBuilder.append('\u201D'); + break; + case CHARACTER_BOLD_BULLET: + currentCueBuilder.append('\u2022'); + break; + case CHARACTER_TM: + currentCueBuilder.append('\u2122'); + break; + case CHARACTER_SMALL_CARONS: + currentCueBuilder.append('\u0161'); + break; + case CHARACTER_SMALL_OE: + currentCueBuilder.append('\u0153'); + break; + case CHARACTER_SM: + currentCueBuilder.append('\u2120'); + break; + case CHARACTER_DIAERESIS_Y: + currentCueBuilder.append('\u0178'); + break; + case CHARACTER_ONE_EIGHTH: + currentCueBuilder.append('\u215B'); + break; + case CHARACTER_THREE_EIGHTHS: + currentCueBuilder.append('\u215C'); + break; + case CHARACTER_FIVE_EIGHTHS: + currentCueBuilder.append('\u215D'); + break; + case CHARACTER_SEVEN_EIGHTHS: + currentCueBuilder.append('\u215E'); + break; + case CHARACTER_VERTICAL_BORDER: + currentCueBuilder.append('\u2502'); + break; + case CHARACTER_UPPER_RIGHT_BORDER: + currentCueBuilder.append('\u2510'); + break; + case CHARACTER_LOWER_LEFT_BORDER: + currentCueBuilder.append('\u2514'); + break; + case CHARACTER_HORIZONTAL_BORDER: + currentCueBuilder.append('\u2500'); + break; + case CHARACTER_LOWER_RIGHT_BORDER: + currentCueBuilder.append('\u2518'); + break; + case CHARACTER_UPPER_LEFT_BORDER: + currentCueBuilder.append('\u250C'); + break; + default: + Log.w(TAG, "Invalid G2 character: " + characterCode); + // The CEA-708 specification doesn't specify what to do in the case of an unexpected + // value in the G2 character range, so we ignore it. + } + } + + private void handleG3Character(int characterCode) { + if (characterCode == 0xA0) { + currentCueBuilder.append('\u33C4'); + } else { + Log.w(TAG, "Invalid G3 character: " + characterCode); + // Substitute any unsupported G3 character with an underscore as per CEA-708 specification. + currentCueBuilder.append('_'); + } + } + + private void handleSetPenAttributes() { + // the SetPenAttributes command contains 2 bytes of data + // first byte + int textTag = serviceBlockPacket.readBits(4); + int offset = serviceBlockPacket.readBits(2); + int penSize = serviceBlockPacket.readBits(2); + // second byte + boolean italicsToggle = serviceBlockPacket.readBit(); + boolean underlineToggle = serviceBlockPacket.readBit(); + int edgeType = serviceBlockPacket.readBits(3); + int fontStyle = serviceBlockPacket.readBits(3); + + currentCueBuilder.setPenAttributes(textTag, offset, penSize, italicsToggle, underlineToggle, + edgeType, fontStyle); + } + + private void handleSetPenColor() { + // the SetPenColor command contains 3 bytes of data + // first byte + int foregroundO = serviceBlockPacket.readBits(2); + int foregroundR = serviceBlockPacket.readBits(2); + int foregroundG = serviceBlockPacket.readBits(2); + int foregroundB = serviceBlockPacket.readBits(2); + int foregroundColor = CueBuilder.getArgbColorFromCeaColor(foregroundR, foregroundG, foregroundB, + foregroundO); + // second byte + int backgroundO = serviceBlockPacket.readBits(2); + int backgroundR = serviceBlockPacket.readBits(2); + int backgroundG = serviceBlockPacket.readBits(2); + int backgroundB = serviceBlockPacket.readBits(2); + int backgroundColor = CueBuilder.getArgbColorFromCeaColor(backgroundR, backgroundG, backgroundB, + backgroundO); + // third byte + serviceBlockPacket.skipBits(2); // null padding + int edgeR = serviceBlockPacket.readBits(2); + int edgeG = serviceBlockPacket.readBits(2); + int edgeB = serviceBlockPacket.readBits(2); + int edgeColor = CueBuilder.getArgbColorFromCeaColor(edgeR, edgeG, edgeB); + + currentCueBuilder.setPenColor(foregroundColor, backgroundColor, edgeColor); + } + + private void handleSetPenLocation() { + // the SetPenLocation command contains 2 bytes of data + // first byte + serviceBlockPacket.skipBits(4); + int row = serviceBlockPacket.readBits(4); + // second byte + serviceBlockPacket.skipBits(2); + int column = serviceBlockPacket.readBits(6); + + currentCueBuilder.setPenLocation(row, column); + } + + private void handleSetWindowAttributes() { + // the SetWindowAttributes command contains 4 bytes of data + // first byte + int fillO = serviceBlockPacket.readBits(2); + int fillR = serviceBlockPacket.readBits(2); + int fillG = serviceBlockPacket.readBits(2); + int fillB = serviceBlockPacket.readBits(2); + int fillColor = CueBuilder.getArgbColorFromCeaColor(fillR, fillG, fillB, fillO); + // second byte + int borderType = serviceBlockPacket.readBits(2); // only the lower 2 bits of borderType + int borderR = serviceBlockPacket.readBits(2); + int borderG = serviceBlockPacket.readBits(2); + int borderB = serviceBlockPacket.readBits(2); + int borderColor = CueBuilder.getArgbColorFromCeaColor(borderR, borderG, borderB); + // third byte + if (serviceBlockPacket.readBit()) { + borderType |= 0x04; // set the top bit of the 3-bit borderType + } + boolean wordWrapToggle = serviceBlockPacket.readBit(); + int printDirection = serviceBlockPacket.readBits(2); + int scrollDirection = serviceBlockPacket.readBits(2); + int justification = serviceBlockPacket.readBits(2); + // fourth byte + // Note that we don't intend to support display effects + serviceBlockPacket.skipBits(8); // effectSpeed(4), effectDirection(2), displayEffect(2) + + currentCueBuilder.setWindowAttributes(fillColor, borderColor, wordWrapToggle, borderType, + printDirection, scrollDirection, justification); + } + + private void handleDefineWindow(int window) { + CueBuilder cueBuilder = cueBuilders[window]; + + // the DefineWindow command contains 6 bytes of data + // first byte + serviceBlockPacket.skipBits(2); // null padding + boolean visible = serviceBlockPacket.readBit(); + boolean rowLock = serviceBlockPacket.readBit(); + boolean columnLock = serviceBlockPacket.readBit(); + int priority = serviceBlockPacket.readBits(3); + // second byte + boolean relativePositioning = serviceBlockPacket.readBit(); + int verticalAnchor = serviceBlockPacket.readBits(7); + // third byte + int horizontalAnchor = serviceBlockPacket.readBits(8); + // fourth byte + int anchorId = serviceBlockPacket.readBits(4); + int rowCount = serviceBlockPacket.readBits(4); + // fifth byte + serviceBlockPacket.skipBits(2); // null padding + int columnCount = serviceBlockPacket.readBits(6); + // sixth byte + serviceBlockPacket.skipBits(2); // null padding + int windowStyle = serviceBlockPacket.readBits(3); + int penStyle = serviceBlockPacket.readBits(3); + + cueBuilder.defineWindow(visible, rowLock, columnLock, priority, relativePositioning, + verticalAnchor, horizontalAnchor, rowCount, columnCount, anchorId, windowStyle, penStyle); + } + + private List getDisplayCues() { + List displayCues = new ArrayList<>(); + for (int i = 0; i < NUM_WINDOWS; i++) { + if (!cueBuilders[i].isEmpty() && cueBuilders[i].isVisible()) { + displayCues.add(cueBuilders[i].build()); + } + } + Collections.sort(displayCues); + return Collections.unmodifiableList(displayCues); + } + + private void resetCueBuilders() { + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i].reset(); + } + } + + private static final class DtvCcPacket { + + public final int sequenceNumber; + public final int packetSize; + public final byte[] packetData; + + int currentIndex; + + public DtvCcPacket(int sequenceNumber, int packetSize) { + this.sequenceNumber = sequenceNumber; + this.packetSize = packetSize; + packetData = new byte[2 * packetSize - 1]; + currentIndex = 0; + } + + } + + // TODO: There is a lot of overlap between Cea708Decoder.CueBuilder and Cea608Decoder.CueBuilder + // which could be refactored into a separate class. + private static final class CueBuilder { + + private static final int RELATIVE_CUE_SIZE = 99; + private static final int VERTICAL_SIZE = 74; + private static final int HORIZONTAL_SIZE = 209; + + private static final int DEFAULT_PRIORITY = 4; + + private static final int MAXIMUM_ROW_COUNT = 15; + + private static final int JUSTIFICATION_LEFT = 0; + private static final int JUSTIFICATION_RIGHT = 1; + private static final int JUSTIFICATION_CENTER = 2; + private static final int JUSTIFICATION_FULL = 3; + + private static final int DIRECTION_LEFT_TO_RIGHT = 0; + private static final int DIRECTION_RIGHT_TO_LEFT = 1; + private static final int DIRECTION_TOP_TO_BOTTOM = 2; + private static final int DIRECTION_BOTTOM_TO_TOP = 3; + + // TODO: Add other border/edge types when utilized. + private static final int BORDER_AND_EDGE_TYPE_NONE = 0; + private static final int BORDER_AND_EDGE_TYPE_UNIFORM = 3; + + public static final int COLOR_SOLID_WHITE = getArgbColorFromCeaColor(2, 2, 2, 0); + public static final int COLOR_SOLID_BLACK = getArgbColorFromCeaColor(0, 0, 0, 0); + public static final int COLOR_TRANSPARENT = getArgbColorFromCeaColor(0, 0, 0, 3); + + // TODO: Add other sizes when utilized. + private static final int PEN_SIZE_STANDARD = 1; + + // TODO: Add other pen font styles when utilized. + private static final int PEN_FONT_STYLE_DEFAULT = 0; + private static final int PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS = 1; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS = 2; + private static final int PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS = 3; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS = 4; + + // TODO: Add other pen offsets when utilized. + private static final int PEN_OFFSET_NORMAL = 1; + + // The window style properties are specified in the CEA-708 specification. + private static final int[] WINDOW_STYLE_JUSTIFICATION = new int[]{ + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_CENTER, + JUSTIFICATION_LEFT + }; + private static final int[] WINDOW_STYLE_PRINT_DIRECTION = new int[]{ + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_TOP_TO_BOTTOM + }; + private static final int[] WINDOW_STYLE_SCROLL_DIRECTION = new int[]{ + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_RIGHT_TO_LEFT + }; + private static final boolean[] WINDOW_STYLE_WORD_WRAP = new boolean[]{ + false, false, false, true, true, true, false + }; + private static final int[] WINDOW_STYLE_FILL = new int[]{ + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK + }; + + // The pen style properties are specified in the CEA-708 specification. + private static final int[] PEN_STYLE_FONT_STYLE = new int[]{ + PEN_FONT_STYLE_DEFAULT, PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS, PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS + }; + private static final int[] PEN_STYLE_EDGE_TYPE = new int[]{ + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_UNIFORM, + BORDER_AND_EDGE_TYPE_UNIFORM + }; + private static final int[] PEN_STYLE_BACKGROUND = new int[]{ + COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_TRANSPARENT}; + + private final List rolledUpCaptions; + private final SpannableStringBuilder captionStringBuilder; + + // Window/Cue properties + private boolean defined; + private boolean visible; + private int priority; + private boolean relativePositioning; + private int verticalAnchor; + private int horizontalAnchor; + private int anchorId; + private int rowCount; + private boolean rowLock; + private int justification; + private int windowStyleId; + private int penStyleId; + private int windowFillColor; + + // Pen/Text properties + private int italicsStartPosition; + private int underlineStartPosition; + private int foregroundColorStartPosition; + private int foregroundColor; + private int backgroundColorStartPosition; + private int backgroundColor; + + public CueBuilder() { + rolledUpCaptions = new LinkedList<>(); + captionStringBuilder = new SpannableStringBuilder(); + reset(); + } + + public boolean isEmpty() { + return !isDefined() || (rolledUpCaptions.isEmpty() && captionStringBuilder.length() == 0); + } + + public void reset() { + clear(); + + defined = false; + visible = false; + priority = DEFAULT_PRIORITY; + relativePositioning = false; + verticalAnchor = 0; + horizontalAnchor = 0; + anchorId = 0; + rowCount = MAXIMUM_ROW_COUNT; + rowLock = true; + justification = JUSTIFICATION_LEFT; + windowStyleId = 0; + penStyleId = 0; + windowFillColor = COLOR_SOLID_BLACK; + + foregroundColor = COLOR_SOLID_WHITE; + backgroundColor = COLOR_SOLID_BLACK; + } + + public void clear() { + rolledUpCaptions.clear(); + captionStringBuilder.clear(); + italicsStartPosition = C.POSITION_UNSET; + underlineStartPosition = C.POSITION_UNSET; + foregroundColorStartPosition = C.POSITION_UNSET; + backgroundColorStartPosition = C.POSITION_UNSET; + } + + public boolean isDefined() { + return defined; + } + + public void setVisibility(boolean visible) { + this.visible = visible; + } + + public boolean isVisible() { + return visible; + } + + public void defineWindow(boolean visible, boolean rowLock, boolean columnLock, int priority, + boolean relativePositioning, int verticalAnchor, int horizontalAnchor, int rowCount, + int columnCount, int anchorId, int windowStyleId, int penStyleId) { + this.defined = true; + this.visible = visible; + this.rowLock = rowLock; + this.priority = priority; + this.relativePositioning = relativePositioning; + this.verticalAnchor = verticalAnchor; + this.horizontalAnchor = horizontalAnchor; + this.anchorId = anchorId; + + // Decoders must add one to rowCount to get the desired number of rows. + if (this.rowCount != rowCount + 1) { + this.rowCount = rowCount + 1; + + // Trim any rolled up captions that are no longer valid, if applicable. + while ((rowLock && (rolledUpCaptions.size() >= this.rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } + + // TODO: Add support for column lock and count. + + if (windowStyleId != 0 && this.windowStyleId != windowStyleId) { + this.windowStyleId = windowStyleId; + // windowStyleId is 1-based. + int windowStyleIdIndex = windowStyleId - 1; + // Note that Border type and border color are the same for all window styles. + setWindowAttributes(WINDOW_STYLE_FILL[windowStyleIdIndex], COLOR_TRANSPARENT, + WINDOW_STYLE_WORD_WRAP[windowStyleIdIndex], BORDER_AND_EDGE_TYPE_NONE, + WINDOW_STYLE_PRINT_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_SCROLL_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_JUSTIFICATION[windowStyleIdIndex]); + } + + if (penStyleId != 0 && this.penStyleId != penStyleId) { + this.penStyleId = penStyleId; + // penStyleId is 1-based. + int penStyleIdIndex = penStyleId - 1; + // Note that pen size, offset, italics, underline, foreground color, and foreground + // opacity are the same for all pen styles. + setPenAttributes(0, PEN_OFFSET_NORMAL, PEN_SIZE_STANDARD, false, false, + PEN_STYLE_EDGE_TYPE[penStyleIdIndex], PEN_STYLE_FONT_STYLE[penStyleIdIndex]); + setPenColor(COLOR_SOLID_WHITE, PEN_STYLE_BACKGROUND[penStyleIdIndex], COLOR_SOLID_BLACK); + } + } + + + public void setWindowAttributes(int fillColor, int borderColor, boolean wordWrapToggle, + int borderType, int printDirection, int scrollDirection, int justification) { + this.windowFillColor = fillColor; + // TODO: Add support for border color and types. + // TODO: Add support for word wrap. + // TODO: Add support for other scroll directions. + // TODO: Add support for other print directions. + this.justification = justification; + + } + + public void setPenAttributes(int textTag, int offset, int penSize, boolean italicsToggle, + boolean underlineToggle, int edgeType, int fontStyle) { + // TODO: Add support for text tags. + // TODO: Add support for other offsets. + // TODO: Add support for other pen sizes. + + if (italicsStartPosition != C.POSITION_UNSET) { + if (!italicsToggle) { + captionStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + italicsStartPosition = C.POSITION_UNSET; + } + } else if (italicsToggle) { + italicsStartPosition = captionStringBuilder.length(); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + if (!underlineToggle) { + captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + underlineStartPosition = C.POSITION_UNSET; + } + } else if (underlineToggle) { + underlineStartPosition = captionStringBuilder.length(); + } + + // TODO: Add support for edge types. + // TODO: Add support for other font styles. + } + + public void setPenColor(int foregroundColor, int backgroundColor, int edgeColor) { + if (foregroundColorStartPosition != C.POSITION_UNSET) { + if (this.foregroundColor != foregroundColor) { + captionStringBuilder.setSpan(new ForegroundColorSpan(this.foregroundColor), + foregroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (foregroundColor != COLOR_SOLID_WHITE) { + foregroundColorStartPosition = captionStringBuilder.length(); + this.foregroundColor = foregroundColor; + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + if (this.backgroundColor != backgroundColor) { + captionStringBuilder.setSpan(new BackgroundColorSpan(this.backgroundColor), + backgroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (backgroundColor != COLOR_SOLID_BLACK) { + backgroundColorStartPosition = captionStringBuilder.length(); + this.backgroundColor = backgroundColor; + } + + // TODO: Add support for edge color. + } + + public void setPenLocation(int row, int column) { + // TODO: Support moving the pen location with a window. + } + + public void backspace() { + int length = captionStringBuilder.length(); + if (length > 0) { + captionStringBuilder.delete(length - 1, length); + } + } + + public void append(char text) { + if (text == '\n') { + rolledUpCaptions.add(buildSpannableString()); + captionStringBuilder.clear(); + + if (italicsStartPosition != C.POSITION_UNSET) { + italicsStartPosition = 0; + } + if (underlineStartPosition != C.POSITION_UNSET) { + underlineStartPosition = 0; + } + if (foregroundColorStartPosition != C.POSITION_UNSET) { + foregroundColorStartPosition = 0; + } + if (backgroundColorStartPosition != C.POSITION_UNSET) { + backgroundColorStartPosition = 0; + } + + while ((rowLock && (rolledUpCaptions.size() >= rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } else { + captionStringBuilder.append(text); + } + } + + public SpannableString buildSpannableString() { + SpannableStringBuilder spannableStringBuilder = + new SpannableStringBuilder(captionStringBuilder); + int length = spannableStringBuilder.length(); + + if (length > 0) { + if (italicsStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (foregroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new ForegroundColorSpan(foregroundColor), + foregroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new BackgroundColorSpan(backgroundColor), + backgroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + return new SpannableString(spannableStringBuilder); + } + + public Cea708Cue build() { + if (isEmpty()) { + // The cue is empty. + return null; + } + + 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()); + + // TODO: Add support for right-to-left languages (i.e. where right would correspond to normal + // alignment). + Alignment alignment; + switch (justification) { + case JUSTIFICATION_FULL: + // TODO: Add support for full justification. + case JUSTIFICATION_LEFT: + alignment = Alignment.ALIGN_NORMAL; + break; + case JUSTIFICATION_RIGHT: + alignment = Alignment.ALIGN_OPPOSITE; + break; + case JUSTIFICATION_CENTER: + alignment = Alignment.ALIGN_CENTER; + break; + default: + throw new IllegalArgumentException("Unexpected justification value: " + justification); + } + + float position; + float line; + if (relativePositioning) { + position = (float) horizontalAnchor / RELATIVE_CUE_SIZE; + line = (float) verticalAnchor / RELATIVE_CUE_SIZE; + } else { + position = (float) horizontalAnchor / HORIZONTAL_SIZE; + line = (float) verticalAnchor / VERTICAL_SIZE; + } + // Apply screen-edge padding to the line and position. + position = (position * 0.9f) + 0.05f; + line = (line * 0.9f) + 0.05f; + + // anchorId specifies where the anchor should be placed on the caption cue/window. The 9 + // possible configurations are as follows: + // 0-----1-----2 + // | | + // 3 4 5 + // | | + // 6-----7-----8 + @AnchorType int verticalAnchorType; + if (anchorId % 3 == 0) { + verticalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId % 3 == 1) { + verticalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + verticalAnchorType = Cue.ANCHOR_TYPE_END; + } + // TODO: Add support for right-to-left languages (i.e. where start is on the right). + @AnchorType int horizontalAnchorType; + if (anchorId / 3 == 0) { + horizontalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId / 3 == 1) { + horizontalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + horizontalAnchorType = Cue.ANCHOR_TYPE_END; + } + + boolean windowColorSet = (windowFillColor != COLOR_SOLID_BLACK); + + return new Cea708Cue(cueString, alignment, line, Cue.LINE_TYPE_FRACTION, verticalAnchorType, + position, horizontalAnchorType, Cue.DIMEN_UNSET, windowColorSet, windowFillColor, + priority); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue) { + return getArgbColorFromCeaColor(red, green, blue, 0); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue, int opacity) { + Assertions.checkIndex(red, 0, 4); + Assertions.checkIndex(green, 0, 4); + Assertions.checkIndex(blue, 0, 4); + Assertions.checkIndex(opacity, 0, 4); + + int alpha; + switch (opacity) { + case 0: + case 1: + // Note the value of '1' is actually FLASH, but we don't support that. + alpha = 255; + break; + case 2: + alpha = 127; + break; + case 3: + alpha = 0; + break; + default: + alpha = 255; + } + + // TODO: Add support for the Alternative Minimum Color List or the full 64 RGB combinations. + + // Return values based on the Minimum Color List + return Color.argb(alpha, + (red > 1 ? 255 : 0), + (green > 1 ? 255 : 0), + (blue > 1 ? 255 : 0)); + } + + } + +} 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 77df9a2173..690723cf15 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,12 +15,13 @@ */ package com.google.android.exoplayer2.trackselection; -import android.util.Pair; +import android.content.Context; 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.RendererConfiguration; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.util.Util; @@ -82,12 +83,14 @@ public abstract class MappingTrackSelector extends TrackSelector { private final SparseArray> selectionOverrides; private final SparseBooleanArray rendererDisabledFlags; + private int tunnelingAudioSessionId; private MappedTrackInfo currentMappedTrackInfo; public MappingTrackSelector() { selectionOverrides = new SparseArray<>(); rendererDisabledFlags = new SparseBooleanArray(); + tunnelingAudioSessionId = C.AUDIO_SESSION_ID_UNSET; } /** @@ -224,12 +227,28 @@ public abstract class MappingTrackSelector extends TrackSelector { invalidate(); } + /** + * Enables or disables tunneling. To enable tunneling, pass an audio session id to use when in + * tunneling mode. Session ids can be generated using + * {@link C#generateAudioSessionIdV21(Context)}. To disable tunneling pass + * {@link C#AUDIO_SESSION_ID_UNSET}. Tunneling will only be activated if it's both enabled and + * supported by the audio and video renderers for the selected tracks. + * + * @param tunnelingAudioSessionId The audio session id to use when tunneling, or + * {@link C#AUDIO_SESSION_ID_UNSET} to disable tunneling. + */ + public void setTunnelingAudioSessionId(int tunnelingAudioSessionId) { + if (this.tunnelingAudioSessionId != tunnelingAudioSessionId) { + this.tunnelingAudioSessionId = tunnelingAudioSessionId; + invalidate(); + } + } + // TrackSelector implementation. @Override - public final Pair selectTracks( - RendererCapabilities[] rendererCapabilities, TrackGroupArray trackGroups) - throws ExoPlaybackException { + public final TrackSelectorResult selectTracks(RendererCapabilities[] rendererCapabilities, + TrackGroupArray trackGroups) throws ExoPlaybackException { // Structures into which data will be written during the selection. The extra item at the end // of each array is to store data associated with track groups that cannot be associated with // any renderer. @@ -297,8 +316,20 @@ public abstract class MappingTrackSelector extends TrackSelector { MappedTrackInfo mappedTrackInfo = new MappedTrackInfo(rendererTrackTypes, rendererTrackGroupArrays, mixedMimeTypeAdaptationSupport, rendererFormatSupports, unassociatedTrackGroupArray); - return Pair.create(new TrackSelectionArray(trackSelections), - mappedTrackInfo); + + // Initialize the renderer configurations to the default configuration for all renderers with + // selections, and null otherwise. + RendererConfiguration[] rendererConfigurations = + new RendererConfiguration[rendererCapabilities.length]; + for (int i = 0; i < rendererCapabilities.length; i++) { + rendererConfigurations[i] = trackSelections[i] != null ? RendererConfiguration.DEFAULT : null; + } + // Configure audio and video renderers to use tunneling if appropriate. + maybeConfigureRenderersForTunneling(rendererCapabilities, rendererTrackGroupArrays, + rendererFormatSupports, rendererConfigurations, trackSelections, tunnelingAudioSessionId); + + return new TrackSelectorResult(trackGroups, new TrackSelectionArray(trackSelections), + mappedTrackInfo, rendererConfigurations); } @Override @@ -345,15 +376,16 @@ public abstract class MappingTrackSelector extends TrackSelector { private static int findRenderer(RendererCapabilities[] rendererCapabilities, TrackGroup group) throws ExoPlaybackException { int bestRendererIndex = rendererCapabilities.length; - int bestSupportLevel = RendererCapabilities.FORMAT_UNSUPPORTED_TYPE; + int bestFormatSupportLevel = RendererCapabilities.FORMAT_UNSUPPORTED_TYPE; for (int rendererIndex = 0; rendererIndex < rendererCapabilities.length; rendererIndex++) { RendererCapabilities rendererCapability = rendererCapabilities[rendererIndex]; for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { - int trackSupportLevel = rendererCapability.supportsFormat(group.getFormat(trackIndex)); - if (trackSupportLevel > bestSupportLevel) { + int formatSupportLevel = rendererCapability.supportsFormat(group.getFormat(trackIndex)) + & RendererCapabilities.FORMAT_SUPPORT_MASK; + if (formatSupportLevel > bestFormatSupportLevel) { bestRendererIndex = rendererIndex; - bestSupportLevel = trackSupportLevel; - if (bestSupportLevel == RendererCapabilities.FORMAT_HANDLED) { + bestFormatSupportLevel = formatSupportLevel; + if (bestFormatSupportLevel == RendererCapabilities.FORMAT_HANDLED) { // We can't do better. return bestRendererIndex; } @@ -400,6 +432,94 @@ public abstract class MappingTrackSelector extends TrackSelector { return mixedMimeTypeAdaptationSupport; } + /** + * Determines whether tunneling should be enabled, replacing {@link RendererConfiguration}s in + * {@code rendererConfigurations} with configurations that enable tunneling on the appropriate + * renderers if so. + * + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which + * {@link TrackSelection}s are to be generated. + * @param rendererTrackGroupArrays An array of {@link TrackGroupArray}s where each entry + * corresponds to the renderer of equal index in {@code renderers}. + * @param rendererFormatSupports Maps every available track to a specific level of support as + * defined by the renderer {@code FORMAT_*} constants. + * @param rendererConfigurations The renderer configurations. Configurations may be replaced with + * ones that enable tunneling as a result of this call. + * @param trackSelections The renderer track selections. + * @param tunnelingAudioSessionId The audio session id to use when tunneling, or + * {@link C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. + */ + private static void maybeConfigureRenderersForTunneling( + RendererCapabilities[] rendererCapabilities, TrackGroupArray[] rendererTrackGroupArrays, + int[][][] rendererFormatSupports, RendererConfiguration[] rendererConfigurations, + TrackSelection[] trackSelections, int tunnelingAudioSessionId) { + if (tunnelingAudioSessionId == C.AUDIO_SESSION_ID_UNSET) { + return; + } + // Check whether we can enable tunneling. To enable tunneling we require exactly one audio and + // one video renderer to support tunneling and have a selection. + int tunnelingAudioRendererIndex = -1; + int tunnelingVideoRendererIndex = -1; + boolean enableTunneling = true; + for (int i = 0; i < rendererCapabilities.length; i++) { + int rendererType = rendererCapabilities[i].getTrackType(); + TrackSelection trackSelection = trackSelections[i]; + if ((rendererType == C.TRACK_TYPE_AUDIO || rendererType == C.TRACK_TYPE_VIDEO) + && trackSelection != null) { + if (rendererSupportsTunneling(rendererFormatSupports[i], rendererTrackGroupArrays[i], + trackSelection)) { + if (rendererType == C.TRACK_TYPE_AUDIO) { + if (tunnelingAudioRendererIndex != -1) { + enableTunneling = false; + break; + } else { + tunnelingAudioRendererIndex = i; + } + } else { + if (tunnelingVideoRendererIndex != -1) { + enableTunneling = false; + break; + } else { + tunnelingVideoRendererIndex = i; + } + } + } + } + } + enableTunneling &= tunnelingAudioRendererIndex != -1 && tunnelingVideoRendererIndex != -1; + if (enableTunneling) { + RendererConfiguration tunnelingRendererConfiguration = + new RendererConfiguration(tunnelingAudioSessionId); + rendererConfigurations[tunnelingAudioRendererIndex] = tunnelingRendererConfiguration; + rendererConfigurations[tunnelingVideoRendererIndex] = tunnelingRendererConfiguration; + } + } + + /** + * Returns whether a renderer supports tunneling for a {@link TrackSelection}. + * + * @param formatSupport The result of {@link RendererCapabilities#supportsFormat} for each + * track, indexed by group index and track index (in that order). + * @param trackGroups The {@link TrackGroupArray}s for the renderer. + * @param selection The track selection. + * @return Whether the renderer supports tunneling for the {@link TrackSelection}. + */ + private static boolean rendererSupportsTunneling(int[][] formatSupport, + TrackGroupArray trackGroups, TrackSelection selection) { + if (selection == null) { + return false; + } + int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup()); + for (int i = 0; i < selection.length(); i++) { + int trackFormatSupport = formatSupport[trackGroupIndex][selection.getIndexInTrackGroup(i)]; + if ((trackFormatSupport & RendererCapabilities.TUNNELING_SUPPORT_MASK) + != RendererCapabilities.TUNNELING_SUPPORTED) { + return false; + } + } + return true; + } + /** * Provides track information for each renderer. */ 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 5a9d3923bf..6c9fbfcb00 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,7 +15,6 @@ */ package com.google.android.exoplayer2.trackselection; -import android.util.Pair; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -47,25 +46,22 @@ public abstract class TrackSelector { } /** - * Generates {@link TrackSelectionArray} for the renderers. + * Performs a track selection for 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 tracks + * are to be selected. * @param trackGroups The available track groups. - * @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. + * @return A {@link TrackSelectorResult} describing the track selections. * @throws ExoPlaybackException If an error occurs selecting tracks. */ - public abstract Pair selectTracks( - RendererCapabilities[] rendererCapabilities, TrackGroupArray trackGroups) - throws ExoPlaybackException; + public abstract TrackSelectorResult selectTracks(RendererCapabilities[] rendererCapabilities, + TrackGroupArray trackGroups) throws ExoPlaybackException; /** - * Called when {@link TrackSelectionArray} previously generated by - * {@link #selectTracks(RendererCapabilities[], TrackGroupArray)} are activated. + * Called when a {@link TrackSelectorResult} previously generated by + * {@link #selectTracks(RendererCapabilities[], TrackGroupArray)} is activated. * - * @param info The information associated with the selections, or null if the selected tracks are - * being cleared. + * @param info The value of {@link TrackSelectorResult#info} in the activated result. */ public abstract void onSelectionActivated(Object info); diff --git a/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java new file mode 100644 index 0000000000..5cdb157570 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2017 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.trackselection; + +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.util.Util; + +/** + * The result of a {@link TrackSelector} operation. + */ +public final class TrackSelectorResult { + + /** + * The groups provided to the {@link TrackSelector}. + */ + public final TrackGroupArray groups; + /** + * A {@link TrackSelectionArray} containing the selection for each renderer. + */ + public final TrackSelectionArray selections; + /** + * An opaque object that will be returned to {@link TrackSelector#onSelectionActivated(Object)} + * should the selections be activated. + */ + public final Object info; + /** + * A {@link RendererConfiguration} for each renderer, to be used with the selections. + */ + public final RendererConfiguration[] rendererConfigurations; + + /** + * @param groups The groups provided to the {@link TrackSelector}. + * @param selections A {@link TrackSelectionArray} containing the selection for each renderer. + * @param info An opaque object that will be returned to + * {@link TrackSelector#onSelectionActivated(Object)} should the selections be activated. + * @param rendererConfigurations A {@link RendererConfiguration} for each renderer, to be used + * with the selections. + */ + public TrackSelectorResult(TrackGroupArray groups, TrackSelectionArray selections, Object info, + RendererConfiguration[] rendererConfigurations) { + this.groups = groups; + this.selections = selections; + this.info = info; + this.rendererConfigurations = rendererConfigurations; + } + + /** + * Returns whether this result is equivalent to {@code other} for all renderers. + * + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned in all cases. + * @return Whether this result is equivalent to {@code other} for all renderers. + */ + public boolean isEquivalent(TrackSelectorResult other) { + if (other == null) { + return false; + } + for (int i = 0; i < selections.length; i++) { + if (!isEquivalent(other, i)) { + return false; + } + } + return true; + } + + /** + * Returns whether this result is equivalent to {@code other} for the renderer at the given index. + * The results are equivalent if they have equal track selections and configurations for the + * renderer. + * + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned in all cases. + * @param index The renderer index to check for equivalence. + * @return Whether this result is equivalent to {@code other} for all renderers. + */ + public boolean isEquivalent(TrackSelectorResult other, int index) { + if (other == null) { + return false; + } + return Util.areEqual(selections.get(index), other.selections.get(index)) + && Util.areEqual(rendererConfigurations[index], other.rendererConfigurations[index]); + } + +} 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 40e814dab3..dc3c398357 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 @@ -735,8 +735,14 @@ public class PlaybackControlView extends FrameLayout { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser && positionView != null) { - positionView.setText(stringForTime(positionValue(progress))); + if (fromUser) { + long position = positionValue(progress); + if (positionView != null) { + positionView.setText(stringForTime(position)); + } + if (player != null && !dragging) { + seekTo(position); + } } } 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 d094266fcc..3349e05eda 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 @@ -65,6 +65,13 @@ import java.util.List; *

  • Default: {@code true}
  • * * + *
  • {@code default_artwork} - Default artwork to use if no artwork available in audio + * streams. + *
      + *
    • Corresponding method: {@link #setDefaultArtwork(Bitmap)}
    • + *
    • Default: {@code null}
    • + *
    + *
  • *
  • {@code use_controller} - Whether playback controls are displayed. *
      *
    • Corresponding method: {@link #setUseController(boolean)}
    • @@ -179,6 +186,7 @@ public final class SimpleExoPlayerView extends FrameLayout { private SimpleExoPlayer player; private boolean useController; private boolean useArtwork; + private Bitmap defaultArtwork; private int controllerShowTimeoutMs; public SimpleExoPlayerView(Context context) { @@ -194,6 +202,7 @@ public final class SimpleExoPlayerView extends FrameLayout { int playerLayoutId = R.layout.exo_simple_player_view; boolean useArtwork = true; + int defaultArtworkId = 0; boolean useController = true; int surfaceType = SURFACE_TYPE_SURFACE_VIEW; int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; @@ -205,6 +214,8 @@ public final class SimpleExoPlayerView extends FrameLayout { playerLayoutId = a.getResourceId(R.styleable.SimpleExoPlayerView_player_layout_id, playerLayoutId); useArtwork = a.getBoolean(R.styleable.SimpleExoPlayerView_use_artwork, useArtwork); + defaultArtworkId = a.getResourceId(R.styleable.SimpleExoPlayerView_default_artwork, + defaultArtworkId); useController = a.getBoolean(R.styleable.SimpleExoPlayerView_use_controller, useController); surfaceType = a.getInt(R.styleable.SimpleExoPlayerView_surface_type, surfaceType); resizeMode = a.getInt(R.styleable.SimpleExoPlayerView_resize_mode, resizeMode); @@ -246,6 +257,9 @@ public final class SimpleExoPlayerView extends FrameLayout { // Artwork view. artworkView = (ImageView) findViewById(R.id.exo_artwork); this.useArtwork = useArtwork && artworkView != null; + if (defaultArtworkId != 0) { + defaultArtwork = BitmapFactory.decodeResource(context.getResources(), defaultArtworkId); + } // Subtitle view. subtitleView = (SubtitleView) findViewById(R.id.exo_subtitles); @@ -351,6 +365,26 @@ public final class SimpleExoPlayerView extends FrameLayout { } } + /** + * Returns the default artwork to display. + */ + public Bitmap getDefaultArtwork() { + return defaultArtwork; + } + + /** + * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is + * present in the media. + * + * @param defaultArtwork the default artwork to display. + */ + public void setDefaultArtwork(Bitmap defaultArtwork) { + if (this.defaultArtwork != defaultArtwork) { + this.defaultArtwork = defaultArtwork; + updateForCurrentTrackSelections(); + } + } + /** * Returns whether the playback controls are enabled. */ @@ -569,6 +603,9 @@ public final class SimpleExoPlayerView extends FrameLayout { } } } + if (setArtworkFromBitmap(defaultArtwork)) { + return; + } } // Artwork disabled or unavailable. hideArtwork(); @@ -580,18 +617,23 @@ public final class SimpleExoPlayerView extends FrameLayout { 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 setArtworkFromBitmap(bitmap); + } + } + return false; + } + + private boolean setArtworkFromBitmap(Bitmap bitmap) { + 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; diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java index 0ddf17cbe9..4a2354e180 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java @@ -65,7 +65,7 @@ public interface DataSource { * @param buffer The buffer into which the read data should be stored. * @param offset The start offset into {@code buffer} at which data should be written. * @param readLength The maximum number of bytes to read. - * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is avaliable + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available * because the end of the opened range has been reached. * @throws IOException If an error occurs reading from the source. */ diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index d251446976..133e71f6e2 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -32,7 +32,7 @@ public final class DataSpec { * The flags that apply to any request for data. */ @Retention(RetentionPolicy.SOURCE) - @IntDef(flag = true, value = {FLAG_ALLOW_GZIP}) + @IntDef(flag = true, value = {FLAG_ALLOW_GZIP, FLAG_ALLOW_CACHING_UNKNOWN_LENGTH}) public @interface Flags {} /** * Permits an underlying network stack to request that the server use gzip compression. @@ -45,7 +45,10 @@ public final class DataSpec { * {@link DataSource#open(DataSpec)} will typically be {@link C#LENGTH_UNSET}. The data read from * {@link DataSource#read(byte[], int, int)} will be the decompressed data. */ - public static final int FLAG_ALLOW_GZIP = 1; + public static final int FLAG_ALLOW_GZIP = 1 << 0; + + /** Permits content to be cached even if its length can not be resolved. */ + public static final int FLAG_ALLOW_CACHING_UNKNOWN_LENGTH = 1 << 1; /** * The source from which data should be read. @@ -76,7 +79,8 @@ public final class DataSpec { */ public final String key; /** - * Request flags. Currently {@link #FLAG_ALLOW_GZIP} is the only supported flag. + * Request flags. Currently {@link #FLAG_ALLOW_GZIP} and + * {@link #FLAG_ALLOW_CACHING_UNKNOWN_LENGTH} are the only supported flags. */ @Flags public final int flags; @@ -167,6 +171,15 @@ public final class DataSpec { this.flags = flags; } + /** + * Returns whether the given flag is set. + * + * @param flag Flag to be checked if it is set. + */ + public boolean isFlagSet(@Flags int flag) { + return (this.flags & flag) == flag; + } + @Override public String toString() { return "DataSpec[" + uri + ", " + Arrays.toString(postBody) + ", " + absoluteStreamPosition diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java index b326c41b18..ca0fda9399 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -230,7 +230,7 @@ public class DefaultHttpDataSource implements HttpDataSource { bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; // Determine the length of the data to be read, after skipping. - if ((dataSpec.flags & DataSpec.FLAG_ALLOW_GZIP) == 0) { + if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { if (dataSpec.length != C.LENGTH_UNSET) { bytesToRead = dataSpec.length; } else { @@ -343,7 +343,7 @@ public class DefaultHttpDataSource implements HttpDataSource { byte[] postBody = dataSpec.postBody; long position = dataSpec.position; long length = dataSpec.length; - boolean allowGzip = (dataSpec.flags & DataSpec.FLAG_ALLOW_GZIP) != 0; + boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); if (!allowCrossProtocolRedirects) { // HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java b/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java index dcfed59145..615eb4df97 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java @@ -15,10 +15,11 @@ */ package com.google.android.exoplayer2.upstream; +import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; /** A {@link Factory} that produces {@link DefaultHttpDataSource} instances. */ -public final class DefaultHttpDataSourceFactory implements Factory { +public final class DefaultHttpDataSourceFactory extends BaseFactory { private final String userAgent; private final TransferListener listener; @@ -75,8 +76,9 @@ public final class DefaultHttpDataSourceFactory implements Factory { } @Override - public DefaultHttpDataSource createDataSource() { + protected DefaultHttpDataSource createDataSourceInternal() { return new DefaultHttpDataSource(userAgent, null, listener, connectTimeoutMillis, readTimeoutMillis, allowCrossProtocolRedirects); } + } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java index f915ee4e24..8df8624102 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java @@ -17,11 +17,13 @@ package com.google.android.exoplayer2.upstream; import android.support.annotation.IntDef; import android.text.TextUtils; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -38,6 +40,86 @@ public interface HttpDataSource extends DataSource { @Override HttpDataSource createDataSource(); + /** + * Sets a default request header field for {@link HttpDataSource} instances subsequently + * created by the factory. Previously created instances are not affected. + * + * @param name The name of the header field. + * @param value The value of the field. + */ + void setDefaultRequestProperty(String name, String value); + + /** + * Clears a default request header field for {@link HttpDataSource} instances subsequently + * created by the factory. Previously created instances are not affected. + * + * @param name The name of the header field. + */ + void clearDefaultRequestProperty(String name); + + /** + * Clears all default request header fields for all {@link HttpDataSource} instances + * subsequently created by the factory. Previously created instances are not affected. + */ + void clearAllDefaultRequestProperties(); + + } + + /** + * Base implementation of {@link Factory} that sets default request properties. + */ + abstract class BaseFactory implements Factory { + + private final HashMap requestProperties; + + public BaseFactory() { + requestProperties = new HashMap<>(); + } + + @Override + public final HttpDataSource createDataSource() { + HttpDataSource dataSource = createDataSourceInternal(); + synchronized (requestProperties) { + for (Map.Entry property : requestProperties.entrySet()) { + dataSource.setRequestProperty(property.getKey(), property.getValue()); + } + } + return dataSource; + } + + @Override + public final void setDefaultRequestProperty(String name, String value) { + Assertions.checkNotNull(name); + Assertions.checkNotNull(value); + synchronized (requestProperties) { + requestProperties.put(name, value); + } + } + + @Override + public final void clearDefaultRequestProperty(String name) { + Assertions.checkNotNull(name); + synchronized (requestProperties) { + requestProperties.remove(name); + } + } + + @Override + public final void clearAllDefaultRequestProperties() { + synchronized (requestProperties) { + requestProperties.clear(); + } + } + + /** + * Called by {@link #createDataSource()} to create a {@link HttpDataSource} instance without + * default request properties set. Default request properties will be set by + * {@link #createDataSource()} before the instance is returned. + * + * @return A {@link HttpDataSource} instance without default request properties set. + */ + protected abstract HttpDataSource createDataSourceInternal(); + } /** 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 c23b609704..c25638ac86 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 @@ -19,6 +19,7 @@ import android.net.Uri; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.Loader.Loadable; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.io.InputStream; @@ -114,7 +115,7 @@ public final class ParsingLoadable implements Loadable { result = parser.parse(dataSource.getUri(), inputStream); } finally { bytesLoaded = inputStream.bytesRead(); - inputStream.close(); + Util.closeQuietly(inputStream); } } 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 d57f3ee140..71397bd403 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 @@ -81,10 +81,12 @@ public final class CacheDataSink implements DataSink { @Override public void open(DataSpec dataSpec) throws CacheDataSinkException { - this.dataSpec = dataSpec; - if (dataSpec.length == C.LENGTH_UNSET) { + if (dataSpec.length == C.LENGTH_UNSET + && !dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH)) { + this.dataSpec = null; return; } + this.dataSpec = dataSpec; dataSpecBytesWritten = 0; try { openNextOutputStream(); @@ -95,7 +97,7 @@ public final class CacheDataSink implements DataSink { @Override public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException { - if (dataSpec.length == C.LENGTH_UNSET) { + if (dataSpec == null) { return; } try { @@ -119,7 +121,7 @@ public final class CacheDataSink implements DataSink { @Override public void close() throws CacheDataSinkException { - if (dataSpec == null || dataSpec.length == C.LENGTH_UNSET) { + if (dataSpec == null) { return; } try { @@ -130,8 +132,10 @@ public final class CacheDataSink implements DataSink { } private void openNextOutputStream() throws IOException { + long maxLength = dataSpec.length == C.LENGTH_UNSET ? maxCacheFileSize + : Math.min(dataSpec.length - dataSpecBytesWritten, maxCacheFileSize); file = cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, - Math.min(dataSpec.length - dataSpecBytesWritten, maxCacheFileSize)); + maxLength); underlyingFileOutputStream = new FileOutputStream(file); if (bufferSize > 0) { if (bufferedOutputStream == null) { 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 index 9e38dabc31..58cc70d68d 100644 --- 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 @@ -67,14 +67,25 @@ import javax.crypto.spec.SecretKeySpec; private boolean changed; private ReusableBufferedOutputStream bufferedOutputStream; - /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ + /** + * Creates a CachedContentIndex which works on the index file in the given cacheDir. + * + * @param cacheDir Directory where the index file is kept. + */ public CachedContentIndex(File cacheDir) { this(cacheDir, null); } - /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ + /** + * Creates a CachedContentIndex which works on the index file in the given cacheDir. + * + * @param cacheDir Directory where the index file is kept. + * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC. + * The key must be 16 bytes long. + */ public CachedContentIndex(File cacheDir, byte[] secretKey) { if (secretKey != null) { + Assertions.checkArgument(secretKey.length == 16); try { cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); secretKeySpec = new SecretKeySpec(secretKey, "AES"); @@ -302,6 +313,9 @@ import javax.crypto.spec.SecretKeySpec; } output.writeInt(hashCode); atomicFile.endWrite(output); + // Avoid calling close twice. Duplicate CipherOutputStream.close calls did + // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/ + output = null; } catch (IOException e) { throw new CacheException(e); } finally { diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java new file mode 100644 index 0000000000..0f08ca40f2 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java @@ -0,0 +1,205 @@ +/* + * 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 com.google.android.exoplayer2.extractor.ChunkIndex; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NavigableSet; +import java.util.TreeSet; + +/** + * Utility class for efficiently tracking regions of data that are stored in a {@link Cache} + * for a given cache key. + */ +public final class CachedRegionTracker implements Cache.Listener { + + private static final String TAG = "CachedRegionTracker"; + + public static final int NOT_CACHED = -1; + public static final int CACHED_TO_END = -2; + + private final Cache cache; + private final String cacheKey; + private final ChunkIndex chunkIndex; + + private final TreeSet regions; + private final Region lookupRegion; + + public CachedRegionTracker(Cache cache, String cacheKey, ChunkIndex chunkIndex) { + this.cache = cache; + this.cacheKey = cacheKey; + this.chunkIndex = chunkIndex; + this.regions = new TreeSet<>(); + this.lookupRegion = new Region(0, 0); + + synchronized (this) { + NavigableSet cacheSpans = cache.addListener(cacheKey, this); + if (cacheSpans != null) { + // Merge the spans into regions. mergeSpan is more efficient when merging from high to low, + // which is why a descending iterator is used here. + Iterator spanIterator = cacheSpans.descendingIterator(); + while (spanIterator.hasNext()) { + CacheSpan span = spanIterator.next(); + mergeSpan(span); + } + } + } + } + + public void release() { + cache.removeListener(cacheKey, this); + } + + /** + * When provided with a byte offset, this method locates the cached region within which the + * offset falls, and returns the approximate end position in milliseconds of that region. If the + * byte offset does not fall within a cached region then {@link #NOT_CACHED} is returned. + * If the cached region extends to the end of the stream, {@link #CACHED_TO_END} is returned. + * + * @param byteOffset The byte offset in the underlying stream. + * @return The end position of the corresponding cache region, {@link #NOT_CACHED}, or + * {@link #CACHED_TO_END}. + */ + public synchronized int getRegionEndTimeMs(long byteOffset) { + lookupRegion.startOffset = byteOffset; + Region floorRegion = regions.floor(lookupRegion); + if (floorRegion == null || byteOffset > floorRegion.endOffset + || floorRegion.endOffsetIndex == -1) { + return NOT_CACHED; + } + int index = floorRegion.endOffsetIndex; + if (index == chunkIndex.length - 1 + && floorRegion.endOffset == (chunkIndex.offsets[index] + chunkIndex.sizes[index])) { + return CACHED_TO_END; + } + long segmentFractionUs = (chunkIndex.durationsUs[index] + * (floorRegion.endOffset - chunkIndex.offsets[index])) / chunkIndex.sizes[index]; + return (int) ((chunkIndex.timesUs[index] + segmentFractionUs) / 1000); + } + + @Override + public synchronized void onSpanAdded(Cache cache, CacheSpan span) { + mergeSpan(span); + } + + @Override + public synchronized void onSpanRemoved(Cache cache, CacheSpan span) { + Region removedRegion = new Region(span.position, span.position + span.length); + + // Look up a region this span falls into. + Region floorRegion = regions.floor(removedRegion); + if (floorRegion == null) { + Log.e(TAG, "Removed a span we were not aware of"); + return; + } + + // Remove it. + regions.remove(floorRegion); + + // Add new floor and ceiling regions, if necessary. + if (floorRegion.startOffset < removedRegion.startOffset) { + Region newFloorRegion = new Region(floorRegion.startOffset, removedRegion.startOffset); + + int index = Arrays.binarySearch(chunkIndex.offsets, newFloorRegion.endOffset); + newFloorRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newFloorRegion); + } + + if (floorRegion.endOffset > removedRegion.endOffset) { + Region newCeilingRegion = new Region(removedRegion.endOffset + 1, floorRegion.endOffset); + newCeilingRegion.endOffsetIndex = floorRegion.endOffsetIndex; + regions.add(newCeilingRegion); + } + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + // Do nothing. + } + + private void mergeSpan(CacheSpan span) { + Region newRegion = new Region(span.position, span.position + span.length); + Region floorRegion = regions.floor(newRegion); + Region ceilingRegion = regions.ceiling(newRegion); + boolean floorConnects = regionsConnect(floorRegion, newRegion); + boolean ceilingConnects = regionsConnect(newRegion, ceilingRegion); + + if (ceilingConnects) { + if (floorConnects) { + // Extend floorRegion to cover both newRegion and ceilingRegion. + floorRegion.endOffset = ceilingRegion.endOffset; + floorRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + } else { + // Extend newRegion to cover ceilingRegion. Add it. + newRegion.endOffset = ceilingRegion.endOffset; + newRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + regions.add(newRegion); + } + regions.remove(ceilingRegion); + } else if (floorConnects) { + // Extend floorRegion to the right to cover newRegion. + floorRegion.endOffset = newRegion.endOffset; + int index = floorRegion.endOffsetIndex; + while (index < chunkIndex.length - 1 + && (chunkIndex.offsets[index + 1] <= floorRegion.endOffset)) { + index++; + } + floorRegion.endOffsetIndex = index; + } else { + // This is a new region. + int index = Arrays.binarySearch(chunkIndex.offsets, newRegion.endOffset); + newRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newRegion); + } + } + + private boolean regionsConnect(Region lower, Region upper) { + return lower != null && upper != null && lower.endOffset == upper.startOffset; + } + + private static class Region implements Comparable { + + /** + * The first byte of the region (inclusive). + */ + public long startOffset; + /** + * End offset of the region (exclusive). + */ + public long endOffset; + /** + * The index in chunkIndex that contains the end offset. May be -1 if the end offset comes + * before the start of the first media chunk (i.e. if the end offset is within the stream + * header). + */ + public int endOffsetIndex; + + public Region(long position, long endOffset) { + this.startOffset = position; + this.endOffset = endOffset; + } + + @Override + public int compareTo(Region another) { + return startOffset < another.startOffset ? -1 + : startOffset == another.startOffset ? 0 : 1; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java new file mode 100644 index 0000000000..ccf9a5b3f5 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java @@ -0,0 +1,95 @@ +/* + * 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.crypto; + +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A wrapping {@link DataSink} that encrypts the data being consumed. + */ +public final class AesCipherDataSink implements DataSink { + + private final DataSink wrappedDataSink; + private final byte[] secretKey; + private final byte[] scratch; + + private AesFlushingCipher cipher; + + /** + * Create an instance whose {@code write} methods have the side effect of overwriting the input + * {@code data}. Use this constructor for maximum efficiency in the case that there is no + * requirement for the input data arrays to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink) { + this(secretKey, wrappedDataSink, null); + } + + /** + * Create an instance whose {@code write} methods are free of side effects. Use this constructor + * when the input data arrays are required to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + * @param scratch Scratch space. Data is decrypted into this array before being written to the + * wrapped {@link DataSink}. It should be of appropriate size for the expected writes. If a + * write is larger than the size of this array the write will still succeed, but multiple + * cipher calls will be required to complete the operation. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink, byte[] scratch) { + this.wrappedDataSink = wrappedDataSink; + this.secretKey = secretKey; + this.scratch = scratch; + } + + @Override + public void open(DataSpec dataSpec) throws IOException { + wrappedDataSink.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (scratch == null) { + // In-place mode. Writes over the input data. + cipher.updateInPlace(data, offset, length); + wrappedDataSink.write(data, offset, length); + } else { + // Use scratch space. The original data remains intact. + int bytesProcessed = 0; + while (bytesProcessed < length) { + int bytesToProcess = Math.min(length - bytesProcessed, scratch.length); + cipher.update(data, offset + bytesProcessed, bytesToProcess, scratch, 0); + wrappedDataSink.write(scratch, 0, bytesToProcess); + bytesProcessed += bytesToProcess; + } + } + } + + @Override + public void close() throws IOException { + cipher = null; + wrappedDataSink.close(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java new file mode 100644 index 0000000000..26ac3b38fa --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -0,0 +1,73 @@ +/* + * 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.crypto; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A {@link DataSource} that decrypts the data read from an upstream source. + */ +public final class AesCipherDataSource implements DataSource { + + private final DataSource upstream; + private final byte[] secretKey; + + private AesFlushingCipher cipher; + + public AesCipherDataSource(byte[] secretKey, DataSource upstream) { + this.upstream = upstream; + this.secretKey = secretKey; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + long dataLength = upstream.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + return dataLength; + } + + @Override + public int read(byte[] data, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + int read = upstream.read(data, offset, readLength); + if (read == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + cipher.updateInPlace(data, offset, read); + return read; + } + + @Override + public void close() throws IOException { + cipher = null; + upstream.close(); + } + + @Override + public Uri getUri() { + return upstream.getUri(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java new file mode 100644 index 0000000000..e093eb3064 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.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.upstream.crypto; + +import com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A flushing variant of a AES/CTR/NoPadding {@link Cipher}. + * + * Unlike a regular {@link Cipher}, the update methods of this class are guaranteed to process all + * of the bytes input (and hence output the same number of bytes). + */ +public final class AesFlushingCipher { + + private final Cipher cipher; + private final int blockSize; + private final byte[] zerosBlock; + private final byte[] flushedBlock; + + private int pendingXorBytes; + + public AesFlushingCipher(int mode, byte[] secretKey, long nonce, long offset) { + try { + cipher = Cipher.getInstance("AES/CTR/NoPadding"); + blockSize = cipher.getBlockSize(); + zerosBlock = new byte[blockSize]; + flushedBlock = new byte[blockSize]; + long counter = offset / blockSize; + int startPadding = (int) (offset % blockSize); + cipher.init(mode, new SecretKeySpec(secretKey, cipher.getAlgorithm().split("/")[0]), + new IvParameterSpec(getInitializationVector(nonce, counter))); + if (startPadding != 0) { + updateInPlace(new byte[startPadding], 0, startPadding); + } + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + public void updateInPlace(byte[] data, int offset, int length) { + update(data, offset, length, data, offset); + } + + public void update(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + // If we previously flushed the cipher by inputting zeros up to a block boundary, then we need + // to manually transform the data that actually ended the block. See the comment below for more + // details. + while (pendingXorBytes > 0) { + out[outOffset] = (byte) (in[inOffset] ^ flushedBlock[blockSize - pendingXorBytes]); + outOffset++; + inOffset++; + pendingXorBytes--; + length--; + if (length == 0) { + return; + } + } + + // Do the bulk of the update. + int written = nonFlushingUpdate(in, inOffset, length, out, outOffset); + if (length == written) { + return; + } + + // We need to finish the block to flush out the remaining bytes. We do so by inputting zeros, + // so that the corresponding bytes output by the cipher are those that would have been XORed + // against the real end-of-block data to transform it. We store these bytes so that we can + // perform the transformation manually in the case of a subsequent call to this method with + // the real data. + int bytesToFlush = length - written; + Assertions.checkState(bytesToFlush < blockSize); + outOffset += written; + pendingXorBytes = blockSize - bytesToFlush; + written = nonFlushingUpdate(zerosBlock, 0, pendingXorBytes, flushedBlock, 0); + Assertions.checkState(written == blockSize); + // The first part of xorBytes contains the flushed data, which we copy out. The remainder + // contains the bytes that will be needed for manual transformation in a subsequent call. + for (int i = 0; i < bytesToFlush; i++) { + out[outOffset++] = flushedBlock[i]; + } + } + + private int nonFlushingUpdate(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + try { + return cipher.update(in, inOffset, length, out, outOffset); + } catch (ShortBufferException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + private byte[] getInitializationVector(long nonce, long counter) { + return ByteBuffer.allocate(16).putLong(nonce).putLong(counter).array(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java new file mode 100644 index 0000000000..ff8841fa9c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java @@ -0,0 +1,44 @@ +/* + * 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.crypto; + +/** + * Utility functions for the crypto package. + */ +/* package */ final class CryptoUtil { + + private CryptoUtil() {} + + /** + * Returns the hash value of the input as a long using the 64 bit FNV-1a hash function. The hash + * values produced by this function are less likely to collide than those produced by + * {@link #hashCode()}. + */ + public static long getFNV64Hash(String input) { + if (input == null) { + return 0; + } + + long hash = 0; + for (int i = 0; i < input.length(); i++) { + hash ^= input.charAt(i); + // This is equivalent to hash *= 0x100000001b3 (the FNV magic prime number). + hash += (hash << 1) + (hash << 4) + (hash << 5) + (hash << 7) + (hash << 8) + (hash << 40); + } + return hash; + } + +} 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 1f148a5fa6..1eb4300142 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 @@ -66,12 +66,12 @@ public final class MimeTypes { public static final String APPLICATION_MP4 = BASE_TYPE_APPLICATION + "/mp4"; public static final String APPLICATION_WEBM = BASE_TYPE_APPLICATION + "/webm"; + public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; public static final String APPLICATION_CEA608 = BASE_TYPE_APPLICATION + "/cea-608"; public static final String APPLICATION_CEA708 = BASE_TYPE_APPLICATION + "/cea-708"; public static final String APPLICATION_SUBRIP = BASE_TYPE_APPLICATION + "/x-subrip"; 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-mp4-vtt"; public static final String APPLICATION_MP4CEA608 = BASE_TYPE_APPLICATION + "/x-mp4-cea-608"; @@ -80,6 +80,7 @@ public final class MimeTypes { 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"; + public static final String APPLICATION_EMSG = BASE_TYPE_APPLICATION + "/x-emsg"; private MimeTypes() {} @@ -217,12 +218,16 @@ public final class MimeTypes { } else if (isVideo(mimeType)) { return C.TRACK_TYPE_VIDEO; } else if (isText(mimeType) || APPLICATION_CEA608.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)) { + || APPLICATION_CEA708.equals(mimeType) || APPLICATION_MP4CEA608.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)) { + } else if (APPLICATION_ID3.equals(mimeType) + || APPLICATION_EMSG.equals(mimeType) + || APPLICATION_SCTE35.equals(mimeType) + || APPLICATION_CAMERA_MOTION.equals(mimeType)) { return C.TRACK_TYPE_METADATA; } else { return C.TRACK_TYPE_UNKNOWN; 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 b8d635a053..ef4aa05cfe 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 @@ -423,27 +423,6 @@ 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}. * @@ -457,6 +436,49 @@ public final class ParsableByteArray { return result; } + /** + * Reads the next {@code length} bytes as UTF-8 characters. A terminating NUL byte is discarded, + * if present. + * + * @param length The number of bytes to read. + * @return The string, not including any terminating NUL byte. + */ + 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); + position += length; + return result; + } + + /** + * Reads up to the next NUL byte (or the limit) as UTF-8 characters. + * + * @return The string not including any terminating NUL byte, or null if the end of the data has + * already been reached. + */ + public String readNullTerminatedString() { + if (bytesLeft() == 0) { + return null; + } + int stringLimit = position; + while (stringLimit < limit && data[stringLimit] != 0) { + stringLimit++; + } + String string = new String(data, position, stringLimit - position); + position = stringLimit; + if (position < limit) { + position++; + } + return string; + } + /** * Reads a line of text. *

      @@ -464,15 +486,15 @@ public final class ParsableByteArray { * ('\n'), or a carriage return followed immediately by a line feed ('\r\n'). The system's default * charset (UTF-8) is used. * - * @return A String containing the contents of the line, not including any line-termination - * characters, or null if the end of the stream has been reached. + * @return The line not including any line-termination characters, or null if the end of the data + * has already been reached. */ public String readLine() { if (bytesLeft() == 0) { return null; } int lineLimit = position; - while (lineLimit < limit && data[lineLimit] != '\n' && data[lineLimit] != '\r') { + while (lineLimit < limit && !Util.isLinebreak(data[lineLimit])) { lineLimit++; } if (lineLimit - position >= 3 && data[position] == (byte) 0xEF 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 index a3d1d4d02e..1db3d2c1f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java @@ -67,6 +67,7 @@ public final class ReusableBufferedOutputStream extends BufferedOutputStream { public void reset(OutputStream out) { Assertions.checkState(closed); this.out = out; + count = 0; closed = false; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/util/SlidingPercentile.java b/library/src/main/java/com/google/android/exoplayer2/util/SlidingPercentile.java index 8b1af1f0c8..c43b1929cb 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/SlidingPercentile.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/SlidingPercentile.java @@ -32,7 +32,7 @@ import java.util.Comparator; * @see Wiki: Moving average * @see Wiki: Selection algorithm */ -public final class SlidingPercentile { +public class SlidingPercentile { // Orderings. private static final Comparator INDEX_COMPARATOR = new Comparator() { 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 4477de7abb..e854c05165 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 @@ -254,6 +254,16 @@ public final class Util { return value.getBytes(Charset.defaultCharset()); // UTF-8 is the default on Android. } + /** + * Returns whether the given character is a carriage return ('\r') or a line feed ('\n'). + * + * @param c The character. + * @return Whether the given character is a linebreak. + */ + public static boolean isLinebreak(int c) { + return c == '\n' || c == '\r'; + } + /** * Converts text to lower case using {@link Locale#US}. * 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 f68b72fb65..62224a64d6 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 @@ -19,6 +19,7 @@ import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; @@ -28,6 +29,7 @@ import android.view.Surface; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; @@ -83,6 +85,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private int lastReportedUnappliedRotationDegrees; private float lastReportedPixelWidthHeightRatio; + private boolean tunneling; + private int tunnelingAudioSessionId; + /* package */ OnFrameRenderedListenerV23 tunnelingOnFrameRenderedListener; + /** * @param context A context. * @param mediaCodecSelector A decoder selector. @@ -172,7 +178,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } } MediaCodecInfo decoderInfo = mediaCodecSelector.getDecoderInfo(mimeType, - requiresSecureDecryption, false); + requiresSecureDecryption); if (decoderInfo == null) { return FORMAT_UNSUPPORTED_SUBTYPE; } @@ -196,13 +202,16 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } int adaptiveSupport = decoderInfo.adaptive ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS; + int tunnelingSupport = decoderInfo.tunneling ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; int formatSupport = decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES; - return adaptiveSupport | formatSupport; + return adaptiveSupport | tunnelingSupport | formatSupport; } @Override protected void onEnabled(boolean joining) throws ExoPlaybackException { super.onEnabled(joining); + tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + tunneling = tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET; eventDispatcher.enabled(decoderCounters); frameReleaseTimeHelper.enable(); } @@ -216,7 +225,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { super.onPositionReset(positionUs, joining); - renderedFirstFrame = false; + clearRenderedFirstFrame(); consecutiveDroppedFrameCount = 0; joiningDeadlineMs = joining && allowedJoiningTimeMs > 0 ? (SystemClock.elapsedRealtime() + allowedJoiningTimeMs) : C.TIME_UNSET; @@ -263,6 +272,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { pendingPixelWidthHeightRatio = Format.NO_VALUE; clearLastReportedVideoSize(); frameReleaseTimeHelper.disable(); + tunnelingOnFrameRenderedListener = null; try { super.onDisabled(); } finally { @@ -287,11 +297,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } private void setSurface(Surface surface) throws ExoPlaybackException { - // 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; - clearLastReportedVideoSize(); - // We only need to actually release and reinitialize the codec if the surface has changed. + // We only need to release and reinitialize the codec if the surface has changed. if (this.surface != surface) { this.surface = surface; int state = getState(); @@ -300,6 +306,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { maybeInitCodec(); } } + // 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. + clearRenderedFirstFrame(); + clearLastReportedVideoSize(); } @Override @@ -310,8 +320,12 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override protected void configureCodec(MediaCodec codec, Format format, MediaCrypto crypto) { codecMaxValues = getCodecMaxValues(format, streamFormats); - MediaFormat mediaFormat = getMediaFormat(format, codecMaxValues, deviceNeedsAutoFrcWorkaround); + MediaFormat mediaFormat = getMediaFormat(format, codecMaxValues, deviceNeedsAutoFrcWorkaround, + tunnelingAudioSessionId); codec.configure(mediaFormat, surface, crypto, 0); + if (Util.SDK_INT >= 23 && tunneling) { + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + } } @Override @@ -328,6 +342,13 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { pendingRotationDegrees = getRotationDegrees(newFormat); } + @Override + protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + if (Util.SDK_INT < 23 && tunneling) { + maybeNotifyRenderedFirstFrame(); + } + } + @Override protected void onOutputFormatChanged(MediaCodec codec, android.media.MediaFormat outputFormat) { boolean hasCrop = outputFormat.containsKey(KEY_CROP_RIGHT) @@ -438,10 +459,12 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } /** - * Returns true if the current frame should be dropped. + * Returns whether the buffer being processed should be dropped. * - * @param earlyUs Time indicating how early the frame is. Negative values indicate late frame. - * @param elapsedRealtimeUs Wall clock time. + * @param earlyUs The time until the buffer should be presented in microseconds. A negative value + * indicates that the buffer is late. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. */ protected boolean shouldDropOutputBuffer(long earlyUs, long elapsedRealtimeUs) { // Drop the frame if we're more than 30ms late rendering the frame. @@ -476,10 +499,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { TraceUtil.endSection(); decoderCounters.renderedOutputBufferCount++; consecutiveDroppedFrameCount = 0; - if (!renderedFirstFrame) { - renderedFirstFrame = true; - eventDispatcher.renderedFirstFrame(surface); - } + maybeNotifyRenderedFirstFrame(); } @TargetApi(21) @@ -490,6 +510,25 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { TraceUtil.endSection(); decoderCounters.renderedOutputBufferCount++; consecutiveDroppedFrameCount = 0; + maybeNotifyRenderedFirstFrame(); + } + + private void clearRenderedFirstFrame() { + renderedFirstFrame = false; + // The first frame notification is triggered by renderOutputBuffer or renderOutputBufferV21 for + // non-tunneled playback, onQueueInputBuffer for tunneled playback prior to API level 23, and + // OnFrameRenderedListenerV23.onFrameRenderedListener for tunneled playback on API level 23 and + // above. + if (Util.SDK_INT >= 23 && tunneling) { + MediaCodec codec = getCodec(); + // If codec is null then the listener will be instantiated in configureCodec. + if (codec != null) { + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + } + } + } + + /* package */ void maybeNotifyRenderedFirstFrame() { if (!renderedFirstFrame) { renderedFirstFrame = true; eventDispatcher.renderedFirstFrame(surface); @@ -528,7 +567,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @SuppressLint("InlinedApi") private static MediaFormat getMediaFormat(Format format, CodecMaxValues codecMaxValues, - boolean deviceNeedsAutoFrcWorkaround) { + boolean deviceNeedsAutoFrcWorkaround, int tunnelingAudioSessionId) { MediaFormat frameworkMediaFormat = format.getFrameworkMediaFormatV16(); // Set the maximum adaptive video dimensions. frameworkMediaFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, codecMaxValues.width); @@ -541,9 +580,19 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { if (deviceNeedsAutoFrcWorkaround) { frameworkMediaFormat.setInteger("auto-frc", 0); } + // Configure tunneling if enabled. + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + configureTunnelingV21(frameworkMediaFormat, tunnelingAudioSessionId); + } return frameworkMediaFormat; } + @TargetApi(21) + private static void configureTunnelingV21(MediaFormat mediaFormat, int tunnelingAudioSessionId) { + mediaFormat.setFeatureEnabled(CodecCapabilities.FEATURE_TunneledPlayback, true); + mediaFormat.setInteger(MediaFormat.KEY_AUDIO_SESSION_ID, tunnelingAudioSessionId); + } + /** * Returns {@link CodecMaxValues} suitable for configuring a codec for {@code format} in a way * that will allow possible adaptation to other compatible formats in {@code streamFormats}. @@ -679,4 +728,22 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } + @TargetApi(23) + private final class OnFrameRenderedListenerV23 implements MediaCodec.OnFrameRenderedListener { + + private OnFrameRenderedListenerV23(MediaCodec codec) { + codec.setOnFrameRenderedListener(this, new Handler()); + } + + @Override + public void onFrameRendered(MediaCodec codec, long presentationTimeUs, long nanoTime) { + if (this != tunnelingOnFrameRenderedListener) { + // Stale event. + return; + } + maybeNotifyRenderedFirstFrame(); + } + + } + } diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_fastforward.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_fastforward.xml new file mode 100644 index 0000000000..4b86e109e9 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_fastforward.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_next.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_next.xml new file mode 100644 index 0000000000..6305bcbc90 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_next.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_pause.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_pause.xml new file mode 100644 index 0000000000..45cd68bed6 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_pause.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_play.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_play.xml new file mode 100644 index 0000000000..c8c4cdb127 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_play.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_previous.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_previous.xml new file mode 100644 index 0000000000..9564a2a350 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_previous.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-anydpi-v21/exo_controls_rewind.xml b/library/src/main/res/drawable-anydpi-v21/exo_controls_rewind.xml new file mode 100644 index 0000000000..976b706170 --- /dev/null +++ b/library/src/main/res/drawable-anydpi-v21/exo_controls_rewind.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/library/src/main/res/drawable-hdpi/exo_controls_fastforward.png b/library/src/main/res/drawable-hdpi/exo_controls_fastforward.png index c65956ab7f..843df84091 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_fastforward.png and b/library/src/main/res/drawable-hdpi/exo_controls_fastforward.png differ diff --git a/library/src/main/res/drawable-hdpi/exo_controls_next.png b/library/src/main/res/drawable-hdpi/exo_controls_next.png index 6e27b8161e..c37541472e 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_next.png and b/library/src/main/res/drawable-hdpi/exo_controls_next.png differ diff --git a/library/src/main/res/drawable-hdpi/exo_controls_pause.png b/library/src/main/res/drawable-hdpi/exo_controls_pause.png index 1d465a41e4..0a23452746 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_pause.png and b/library/src/main/res/drawable-hdpi/exo_controls_pause.png differ diff --git a/library/src/main/res/drawable-hdpi/exo_controls_play.png b/library/src/main/res/drawable-hdpi/exo_controls_play.png index 2746d17fb1..e98e2b9cbe 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_play.png and b/library/src/main/res/drawable-hdpi/exo_controls_play.png differ diff --git a/library/src/main/res/drawable-hdpi/exo_controls_previous.png b/library/src/main/res/drawable-hdpi/exo_controls_previous.png index 85b3766904..3eae5c883b 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_previous.png and b/library/src/main/res/drawable-hdpi/exo_controls_previous.png differ diff --git a/library/src/main/res/drawable-hdpi/exo_controls_rewind.png b/library/src/main/res/drawable-hdpi/exo_controls_rewind.png index a4ac181777..36537d3b73 100644 Binary files a/library/src/main/res/drawable-hdpi/exo_controls_rewind.png and b/library/src/main/res/drawable-hdpi/exo_controls_rewind.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_fastforward.png b/library/src/main/res/drawable-ldpi/exo_controls_fastforward.png index 1b4d9dbef9..19b9e6015c 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_fastforward.png and b/library/src/main/res/drawable-ldpi/exo_controls_fastforward.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_next.png b/library/src/main/res/drawable-ldpi/exo_controls_next.png index 99927fd27b..d4872037aa 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_next.png and b/library/src/main/res/drawable-ldpi/exo_controls_next.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_pause.png b/library/src/main/res/drawable-ldpi/exo_controls_pause.png index 3b98d66688..616ec42f39 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_pause.png and b/library/src/main/res/drawable-ldpi/exo_controls_pause.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_play.png b/library/src/main/res/drawable-ldpi/exo_controls_play.png index e7c19724bb..5d1c702892 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_play.png and b/library/src/main/res/drawable-ldpi/exo_controls_play.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_previous.png b/library/src/main/res/drawable-ldpi/exo_controls_previous.png index df043228d0..930534d312 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_previous.png and b/library/src/main/res/drawable-ldpi/exo_controls_previous.png differ diff --git a/library/src/main/res/drawable-ldpi/exo_controls_rewind.png b/library/src/main/res/drawable-ldpi/exo_controls_rewind.png index 28843f9fb0..83d71782f6 100644 Binary files a/library/src/main/res/drawable-ldpi/exo_controls_rewind.png and b/library/src/main/res/drawable-ldpi/exo_controls_rewind.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_fastforward.png b/library/src/main/res/drawable-mdpi/exo_controls_fastforward.png index 170dd2daaa..ee3efe1d69 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_fastforward.png and b/library/src/main/res/drawable-mdpi/exo_controls_fastforward.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_next.png b/library/src/main/res/drawable-mdpi/exo_controls_next.png index fcd73d90e7..9d4d7469ed 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_next.png and b/library/src/main/res/drawable-mdpi/exo_controls_next.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_pause.png b/library/src/main/res/drawable-mdpi/exo_controls_pause.png index 3e6b2a17b5..f54c942201 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_pause.png and b/library/src/main/res/drawable-mdpi/exo_controls_pause.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_play.png b/library/src/main/res/drawable-mdpi/exo_controls_play.png index 7966bbc516..dd0c142859 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_play.png and b/library/src/main/res/drawable-mdpi/exo_controls_play.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_previous.png b/library/src/main/res/drawable-mdpi/exo_controls_previous.png index b653d05b9f..950e213d2f 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_previous.png and b/library/src/main/res/drawable-mdpi/exo_controls_previous.png differ diff --git a/library/src/main/res/drawable-mdpi/exo_controls_rewind.png b/library/src/main/res/drawable-mdpi/exo_controls_rewind.png index 5489180eb1..e75efae189 100644 Binary files a/library/src/main/res/drawable-mdpi/exo_controls_rewind.png and b/library/src/main/res/drawable-mdpi/exo_controls_rewind.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_fastforward.png b/library/src/main/res/drawable-xhdpi/exo_controls_fastforward.png index 60f7e92181..ead712cfe9 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_fastforward.png and b/library/src/main/res/drawable-xhdpi/exo_controls_fastforward.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_next.png b/library/src/main/res/drawable-xhdpi/exo_controls_next.png index 4def965cec..bc1ebf83c5 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_next.png and b/library/src/main/res/drawable-xhdpi/exo_controls_next.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_pause.png b/library/src/main/res/drawable-xhdpi/exo_controls_pause.png index 6bd3d482e1..1c868f1831 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_pause.png and b/library/src/main/res/drawable-xhdpi/exo_controls_pause.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_play.png b/library/src/main/res/drawable-xhdpi/exo_controls_play.png index ccfef18056..f2f934413e 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_play.png and b/library/src/main/res/drawable-xhdpi/exo_controls_play.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_previous.png b/library/src/main/res/drawable-xhdpi/exo_controls_previous.png index c4472ae2d9..d197eff873 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_previous.png and b/library/src/main/res/drawable-xhdpi/exo_controls_previous.png differ diff --git a/library/src/main/res/drawable-xhdpi/exo_controls_rewind.png b/library/src/main/res/drawable-xhdpi/exo_controls_rewind.png index 167d10e58b..3340ef9bd2 100644 Binary files a/library/src/main/res/drawable-xhdpi/exo_controls_rewind.png and b/library/src/main/res/drawable-xhdpi/exo_controls_rewind.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_fastforward.png b/library/src/main/res/drawable-xxhdpi/exo_controls_fastforward.png index ab9e022fbf..e1c6cae292 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_fastforward.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_fastforward.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_next.png b/library/src/main/res/drawable-xxhdpi/exo_controls_next.png index ce0a14325a..232f09e910 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_next.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_next.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_pause.png b/library/src/main/res/drawable-xxhdpi/exo_controls_pause.png index 9a36b17cb8..50a545db4d 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_pause.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_pause.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_play.png b/library/src/main/res/drawable-xxhdpi/exo_controls_play.png index 41f76bbf99..08508c5015 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_play.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_play.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_previous.png b/library/src/main/res/drawable-xxhdpi/exo_controls_previous.png index d4688741b9..f71acc4875 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_previous.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_previous.png differ diff --git a/library/src/main/res/drawable-xxhdpi/exo_controls_rewind.png b/library/src/main/res/drawable-xxhdpi/exo_controls_rewind.png index 8ebb2ccf30..db0555f9e5 100644 Binary files a/library/src/main/res/drawable-xxhdpi/exo_controls_rewind.png and b/library/src/main/res/drawable-xxhdpi/exo_controls_rewind.png differ diff --git a/library/src/main/res/values/attrs.xml b/library/src/main/res/values/attrs.xml index b5c01b4575..c73bfb0a3c 100644 --- a/library/src/main/res/values/attrs.xml +++ b/library/src/main/res/values/attrs.xml @@ -37,6 +37,7 @@ + diff --git a/playbacktests/build.gradle b/playbacktests/build.gradle index c099e2c86e..c53793b534 100644 --- a/playbacktests/build.gradle +++ b/playbacktests/build.gradle @@ -21,17 +21,6 @@ android { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' - } - } - - lintOptions { - abortOnError false - } } dependencies { diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java index 8c1ee45e0e..6b561bc81c 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTest.java @@ -26,13 +26,14 @@ 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.Format; 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.DefaultDrmSessionManager; 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; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; @@ -275,7 +276,7 @@ public final class DashTest extends ActivityInstrumentationTestCase2 buildDrmSessionManager( + protected final DefaultDrmSessionManager buildDrmSessionManager( final String userAgent) { - StreamingDrmSessionManager drmSessionManager = null; + DefaultDrmSessionManager drmSessionManager = null; if (isWidevineEncrypted) { try { // Force L3 if secure decoder is not available. - boolean forceL3Widevine = - MediaCodecUtil.getDecoderInfo(videoMimeType, true, false) == null; + boolean forceL3Widevine = MediaCodecUtil.getDecoderInfo(videoMimeType, true) == null; MediaDrm mediaDrm = new MediaDrm(WIDEVINE_UUID); String securityProperty = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY); String widevineContentId = forceL3Widevine ? WIDEVINE_SW_CRYPTO_CONTENT_ID @@ -716,7 +716,7 @@ public final class DashTest extends ActivityInstrumentationTestCase2 trackIndices = new ArrayList<>(); // Always select explicitly listed representations. for (String formatId : formatIds) { - trackIndices.add(getTrackIndex(trackGroup, formatId)); + int trackIndex = getTrackIndex(trackGroup, formatId); + Log.d(TAG, "Adding base video format: " + + Format.toLogString(trackGroup.getFormat(trackIndex))); + trackIndices.add(trackIndex); } // Select additional video representations, if supported by the device. if (canIncludeAdditionalFormats) { for (int i = 0; i < trackGroup.length; i++) { if (!trackIndices.contains(i) && isFormatHandled(formatSupport[i])) { - Log.d(TAG, "Adding video format: " + trackGroup.getFormat(i).id); + Log.d(TAG, "Adding extra video format: " + + Format.toLogString(trackGroup.getFormat(i))); trackIndices.add(i); } } 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 index e279bc8ae8..ede172ad29 100644 --- 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 @@ -19,6 +19,7 @@ import android.annotation.TargetApi; import android.content.Context; import android.os.Handler; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.LoadControl; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.SimpleExoPlayer; @@ -66,16 +67,14 @@ public class DebugSimpleExoPlayer extends SimpleExoPlayer { private int startIndex; private int queueSize; private int bufferCount; + private int minimumInsertIndex; public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, Handler eventHandler, DrmSessionManager drmSessionManager, - VideoRendererEventListener eventListener, - int maxDroppedFrameCountToNotify) { + VideoRendererEventListener eventListener, int maxDroppedFrameCountToNotify) { super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, false, eventHandler, eventListener, maxDroppedFrameCountToNotify); - startIndex = 0; - queueSize = 0; } @Override @@ -90,6 +89,14 @@ public class DebugSimpleExoPlayer extends SimpleExoPlayer { clearTimestamps(); } + @Override + protected void onInputFormatChanged(Format newFormat) throws ExoPlaybackException { + super.onInputFormatChanged(newFormat); + // Ensure timestamps of buffers queued after this format change are never inserted into the + // queue of expected output timestamps before those of buffers that have already been queued. + minimumInsertIndex = startIndex + queueSize; + } + @Override protected void onQueueInputBuffer(DecoderInputBuffer buffer) { insertTimestamp(buffer.timeUs); @@ -111,10 +118,11 @@ public class DebugSimpleExoPlayer extends SimpleExoPlayer { startIndex = 0; queueSize = 0; bufferCount = 0; + minimumInsertIndex = 0; } private void insertTimestamp(long presentationTimeUs) { - for (int i = startIndex + queueSize - 1; i >= startIndex; i--) { + for (int i = startIndex + queueSize - 1; i >= minimumInsertIndex; i--) { if (presentationTimeUs >= timestampsList[i]) { timestampsList[i + 1] = presentationTimeUs; queueSize++; @@ -122,20 +130,22 @@ public class DebugSimpleExoPlayer extends SimpleExoPlayer { } timestampsList[i + 1] = timestampsList[i]; } - timestampsList[startIndex] = presentationTimeUs; + timestampsList[minimumInsertIndex] = presentationTimeUs; queueSize++; } private void maybeShiftTimestampsList() { if (startIndex + queueSize == ARRAY_SIZE) { System.arraycopy(timestampsList, startIndex, timestampsList, 0, queueSize); + minimumInsertIndex -= startIndex; startIndex = 0; } } private long dequeueTimestamp() { - startIndex++; queueSize--; + startIndex++; + minimumInsertIndex = Math.max(minimumInsertIndex, startIndex); 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 dfecdd236a..7bf8985b64 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 @@ -247,7 +247,7 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen @Override public void onAudioInputFormatChanged(Format format) { - Log.d(tag, "audioFormatChanged [" + format.id + "]"); + Log.d(tag, "audioFormatChanged [" + Format.toLogString(format) + "]"); } @Override @@ -277,7 +277,7 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen @Override public void onVideoInputFormatChanged(Format format) { - Log.d(tag, "videoFormatChanged [" + format.id + "]"); + Log.d(tag, "videoFormatChanged [" + Format.toLogString(format) + "]"); } @Override diff --git a/testutils/build.gradle b/testutils/build.gradle index 61bb50f74d..83ff065f9a 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -1,3 +1,16 @@ +// Copyright (C) 2017 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. apply plugin: 'com.android.library' android { @@ -8,13 +21,6 @@ android { minSdkVersion 9 targetSdkVersion project.ext.targetSdkVersion } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } } dependencies {