diff --git a/.gitignore b/.gitignore index db5a8c4305..4731d5ba99 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,12 @@ local.properties proguard.cfg proguard-project.txt +# Bazel +bazel-bin +bazel-genfiles +bazel-out +bazel-testlogs + # Other .DS_Store cmake-build-debug @@ -66,3 +72,6 @@ extensions/cronet/jniLibs/* extensions/cronet/libs/* !extensions/cronet/libs/README.md +# Cast receiver +cast_receiver_app/external-js +cast_receiver_app/bazel-cast_receiver_app diff --git a/.hgignore b/.hgignore index f7c3656f65..36d3268005 100644 --- a/.hgignore +++ b/.hgignore @@ -44,6 +44,12 @@ local.properties proguard.cfg proguard-project.txt +# Bazel +bazel-bin +bazel-genfiles +bazel-out +bazel-testlogs + # Other .DS_Store cmake-build-debug @@ -69,3 +75,7 @@ extensions/cronet/jniLibs/* !extensions/cronet/jniLibs/README.md extensions/cronet/libs/* !extensions/cronet/libs/README.md + +# Cast receiver +cast_receiver_app/external-js +cast_receiver_app/bazel-cast_receiver_app diff --git a/README.md b/README.md index 37967dd527..03f16bd655 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ repository and depend on the modules locally. ### From JCenter ### +#### 1. Add repositories #### + The easiest way to get started using ExoPlayer is to add it as a gradle dependency. You need to make sure you have the Google and JCenter repositories included in the `build.gradle` file in the root of your project: @@ -38,6 +40,8 @@ repositories { } ``` +#### 2. Add ExoPlayer module dependencies #### + Next add a dependency in the `build.gradle` file of your app module. The following will add a dependency to the full library: @@ -45,15 +49,7 @@ following will add a dependency to the full library: implementation 'com.google.android.exoplayer:exoplayer:2.X.X' ``` -where `2.X.X` is your preferred version. If not enabled already, you also need -to turn on Java 8 support in all `build.gradle` files depending on ExoPlayer, by -adding the following to the `android` section: - -```gradle -compileOptions { - targetCompatibility JavaVersion.VERSION_1_8 -} -``` +where `2.X.X` is your preferred version. As an alternative to the full library, you can depend on only the library modules that you actually need. For example the following will add dependencies @@ -87,6 +83,32 @@ JCenter can be found on [Bintray][]. [extensions directory]: https://github.com/google/ExoPlayer/tree/release-v2/extensions/ [Bintray]: https://bintray.com/google/exoplayer +#### 3. Turn on Java 8 support #### + +If not enabled already, you also need to turn on Java 8 support in all +`build.gradle` files depending on ExoPlayer, by adding the following to the +`android` section: + +```gradle +compileOptions { + targetCompatibility JavaVersion.VERSION_1_8 +} +``` + +Note that if you want to use Java 8 features in your own code, the following +additional options need to be set: + +```gradle +// For Java compilers: +compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 +} +// For Kotlin compilers: +kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 +} +``` + ### Locally ### Cloning the repository and depending on the modules locally is required when diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ec11b5b7ed..053198de57 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,13 +5,90 @@ * Support for playing spherical videos on Daydream. * Improve decoder re-use between playbacks. TODO: Write and link a blog post here ([#2826](https://github.com/google/ExoPlayer/issues/2826)). -* Add options for controlling audio track selections to `DefaultTrackSelector` - ([#3314](https://github.com/google/ExoPlayer/issues/3314)). +* Track selection: + * Add options for controlling audio track selections to `DefaultTrackSelector` + ([#3314](https://github.com/google/ExoPlayer/issues/3314)). + * Update `TrackSelection.Factory` interface to support creating all track + selections together. * Do not retry failed loads whose error is `FileNotFoundException`. -* Prevent Cea608Decoder from generating Subtitles with null Cues list -* Caching: Cache data with unknown length by default. The previous flag to opt in - to this behavior (`DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH`) has been - replaced with an opt out flag (`DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN`). +* Offline: + * Speed up removal of segmented downloads + ([#5136](https://github.com/google/ExoPlayer/issues/5136)). + * Add `setStreamKeys` method to factories of DASH, SmoothStreaming and HLS + media sources to simplify filtering by downloaded streams. +* Caching: + * Improve performance of `SimpleCache` + ([#4253](https://github.com/google/ExoPlayer/issues/4253)). + * Cache data with unknown length by default. The previous flag to opt in to + this behavior (`DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH`) has been + replaced with an opt out flag + (`DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN`). +* DownloadManager: + * Create only one task for all DownloadActions for the same content. + * Rename TaskState to DownloadState. + * Add new states to DownloadState. + * Replace DownloadState.action with DownloadAction fields. +* DRM: Fix black flicker when keys rotate in DRM protected content + ([#3561](https://github.com/google/ExoPlayer/issues/3561)). +* Add support for SHOUTcast ICY metadata + ([#3735](https://github.com/google/ExoPlayer/issues/3735)). +* CEA-608: Improved conformance to the specification + ([#3860](https://github.com/google/ExoPlayer/issues/3860)). +* IMA extension: Require setting the `Player` on `AdsLoader` instances before + playback. +* Add `Handler` parameter to `ConcatenatingMediaSource` methods which take a + callback `Runnable`. +* Remove `player` and `isTopLevelSource` parameters from `MediaSource.prepare`. +* Change signature of `PlayerNotificationManager.NotificationListener` to better + fit service requirements. Remove ability to set a custom stop action. +* Add workaround for video quality problems with Amlogic decoders + ([#5003](https://github.com/google/ExoPlayer/issues/5003)). +* Associate fatal player errors of type SOURCE with the loading source in + `AnalyticsListener.EventTime` + ([#5407](https://github.com/google/ExoPlayer/issues/5407)). + +### 2.9.4 ### + +* IMA extension: Clear ads loader listeners on release + ([#4114](https://github.com/google/ExoPlayer/issues/4114)). +* SmoothStreaming: Fix support for subtitles in DRM protected streams + ([#5378](https://github.com/google/ExoPlayer/issues/5378)). +* FFmpeg extension: Treat invalid data errors as non-fatal to match the behavior + of MediaCodec ([#5293](https://github.com/google/ExoPlayer/issues/5293)). +* GVR extension: upgrade GVR SDK dependency to 1.190.0. +* Fix issue where sending callbacks for playlist changes may cause problems + because of parallel player access + ([#5240](https://github.com/google/ExoPlayer/issues/5240)). +* Fix issue with reusing a `ClippingMediaSource` with an inner + `ExtractorMediaSource` and a non-zero start position + ([#5351](https://github.com/google/ExoPlayer/issues/5351)). +* Fix issue where uneven track durations in MP4 streams can cause OOM problems + ([#3670](https://github.com/google/ExoPlayer/issues/3670)). +* Add `startPositionUs` to `MediaSource.createPeriod`. This fixes an issue where + using lazy preparation in `ConcatenatingMediaSource` with an + `ExtractorMediaSource` overrides initial seek positions + ([#5350](https://github.com/google/ExoPlayer/issues/5350)). +* Add subtext to the `MediaDescriptionAdapter` of the + `PlayerNotificationManager`. + +### 2.9.3 ### + +* Captions: Support PNG subtitles in SMPTE-TT + ([#1583](https://github.com/google/ExoPlayer/issues/1583)). +* MPEG-TS: Use random access indicators to minimize the need for + `FLAG_ALLOW_NON_IDR_KEYFRAMES`. +* Downloading: Reduce time taken to remove downloads + ([#5136](https://github.com/google/ExoPlayer/issues/5136)). +* MP3: + * Use the true bitrate for constant-bitrate MP3 seeking. + * Fix issue where streams would play twice on some Samsung devices + ([#4519](https://github.com/google/ExoPlayer/issues/4519)). +* Fix regression where some audio formats were incorrectly marked as being + unplayable due to under-reporting of platform decoder capabilities + ([#5145](https://github.com/google/ExoPlayer/issues/5145)). +* Fix decode-only frame skipping on Nvidia Shield TV devices. +* Workaround for MiTV (dangal) issue when swapping output surface + ([#5169](https://github.com/google/ExoPlayer/issues/5169)). ### 2.9.2 ### @@ -60,10 +137,10 @@ * DASH: Parse ProgramInformation element if present in the manifest. * HLS: * Add constructor to `DefaultHlsExtractorFactory` for adding TS payload - reader factory flags. + reader factory flags + ([#4861](https://github.com/google/ExoPlayer/issues/4861)). * Fix bug in segment sniffing ([#5039](https://github.com/google/ExoPlayer/issues/5039)). - ([#4861](https://github.com/google/ExoPlayer/issues/4861)). * SubRip: Add support for alignment tags, and remove tags from the displayed captions ([#4306](https://github.com/google/ExoPlayer/issues/4306)). * Fix issue with blind seeking to windows with non-zero offset in a @@ -1125,7 +1202,7 @@ [here](https://medium.com/google-exoplayer/customizing-exoplayers-ui-components-728cf55ee07a#.9ewjg7avi). * Robustness improvements when handling MediaSource timeline changes and MediaPeriod transitions. -* EIA608: Support for caption styling and positioning. +* CEA-608: Support for caption styling and positioning. * MPEG-TS: Improved support: * Support injection of custom TS payload readers. * Support injection of custom section payload readers. @@ -1369,8 +1446,8 @@ V2 release. (#801). * MP3: Fix playback of some streams when stream length is unknown. * ID3: Support multiple frames of the same type in a single tag. -* EIA608: Correctly handle repeated control characters, fixing an issue in which - captions would immediately disappear. +* CEA-608: Correctly handle repeated control characters, fixing an issue in + which captions would immediately disappear. * AVC3: Fix decoder failures on some MediaTek devices in the case where the first buffer fed to the decoder does not start with SPS/PPS NAL units. * Misc bug fixes. diff --git a/constants.gradle b/constants.gradle index cac4f6d78b..716ddbadba 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.9.2' - releaseVersionCode = 2009002 + releaseVersion = '2.9.4' + releaseVersionCode = 2009004 // Important: ExoPlayer specifies a minSdkVersion of 14 because various // components provided by the library may be of use on older devices. // However, please note that the core media playback functionality provided diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle index 915bc10b7c..8af52a787e 100644 --- a/demos/cast/build.gradle +++ b/demos/cast/build.gradle @@ -49,6 +49,16 @@ android { disable 'MissingTranslation' } + flavorDimensions "receiver" + + productFlavors { + defaultCast { + dimension "receiver" + manifestPlaceholders = + [castOptionsProvider: "com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"] + } + } + } dependencies { diff --git a/demos/cast/src/main/AndroidManifest.xml b/demos/cast/src/main/AndroidManifest.xml index ae16776333..c556721863 100644 --- a/demos/cast/src/main/AndroidManifest.xml +++ b/demos/cast/src/main/AndroidManifest.xml @@ -23,7 +23,7 @@ android:largeHeap="true" android:allowBackup="false"> + android:value="${castOptionsProvider}" /> SAMPLES; + public static final List SAMPLES; static { // App samples. - ArrayList samples = new ArrayList<>(); - MediaItem.Builder sampleBuilder = new MediaItem.Builder(); + ArrayList samples = new ArrayList<>(); samples.add( - sampleBuilder - .setTitle("DASH (clear,MP4,H264)") - .setMimeType(MIME_TYPE_DASH) - .setMedia("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd") - .buildAndClear()); - + new Sample( + "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd", + "Clear DASH: Tears", + MIME_TYPE_DASH)); samples.add( - sampleBuilder - .setTitle("Tears of Steel (HLS)") - .setMimeType(MIME_TYPE_HLS) - .setMedia( - "https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/" - + "hls/TearsOfSteel.m3u8") - .buildAndClear()); - + new Sample( + "https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/" + + "hls/TearsOfSteel.m3u8", + "Clear HLS: Tears of Steel", + MIME_TYPE_HLS)); samples.add( - sampleBuilder - .setTitle("HLS Basic (TS)") - .setMimeType(MIME_TYPE_HLS) - .setMedia( - "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3" - + "/bipbop_4x3_variant.m3u8") - .buildAndClear()); - + new Sample( + "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3" + + "/bipbop_4x3_variant.m3u8", + "Clear HLS: Basic 4x3", + MIME_TYPE_HLS)); samples.add( - sampleBuilder - .setTitle("Dizzy (MP4)") - .setMimeType(MIME_TYPE_VIDEO_MP4) - .setMedia("https://html5demos.com/assets/dizzy.mp4") - .buildAndClear()); + new Sample( + "https://html5demos.com/assets/dizzy.mp4", "Clear MP4: Dizzy", MIME_TYPE_VIDEO_MP4)); SAMPLES = Collections.unmodifiableList(samples); } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java index 8ebfee1294..058adf7c9c 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.castdemo; import android.content.Context; import android.os.Bundle; -import android.support.annotation.Nullable; import android.support.v4.graphics.ColorUtils; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; @@ -42,6 +41,8 @@ import com.google.android.exoplayer2.ui.PlayerView; import com.google.android.gms.cast.CastMediaControlIntent; import com.google.android.gms.cast.framework.CastButtonFactory; import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.dynamite.DynamiteModule; +import java.util.Collections; /** * An activity that plays video using {@link SimpleExoPlayer} and supports casting using ExoPlayer's @@ -50,6 +51,8 @@ import com.google.android.gms.cast.framework.CastContext; public class MainActivity extends AppCompatActivity implements OnClickListener, PlayerManager.QueuePositionListener { + private final MediaItem.Builder mediaItemBuilder; + private PlayerView localPlayerView; private PlayerControlView castControlView; private PlayerManager playerManager; @@ -57,13 +60,30 @@ public class MainActivity extends AppCompatActivity private MediaQueueListAdapter mediaQueueListAdapter; private CastContext castContext; + public MainActivity() { + mediaItemBuilder = new MediaItem.Builder(); + } + // Activity lifecycle methods. @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Getting the cast context later than onStart can cause device discovery not to take place. - castContext = CastContext.getSharedInstance(this); + try { + castContext = CastContext.getSharedInstance(this); + } catch (RuntimeException e) { + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof DynamiteModule.LoadingException) { + setContentView(R.layout.cast_context_error_message_layout); + return; + } + cause = cause.getCause(); + } + // Unknown error. We propagate it. + throw e; + } setContentView(R.layout.main_activity); @@ -93,6 +113,10 @@ public class MainActivity extends AppCompatActivity @Override public void onResume() { super.onResume(); + if (castContext == null) { + // There is no Cast context to work with. Do nothing. + return; + } String applicationId = castContext.getCastOptions().getReceiverApplicationId(); switch (applicationId) { case CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID: @@ -113,6 +137,10 @@ public class MainActivity extends AppCompatActivity @Override public void onPause() { super.onPause(); + if (castContext == null) { + // Nothing to release. + return; + } mediaQueueListAdapter.notifyItemRangeRemoved(0, mediaQueueListAdapter.getItemCount()); mediaQueueList.setAdapter(null); playerManager.release(); @@ -154,7 +182,19 @@ public class MainActivity extends AppCompatActivity sampleList.setAdapter(new SampleListAdapter(this)); sampleList.setOnItemClickListener( (parent, view, position, id) -> { - playerManager.addItem(DemoUtil.SAMPLES.get(position)); + DemoUtil.Sample sample = DemoUtil.SAMPLES.get(position); + mediaItemBuilder + .clear() + .setMedia(sample.uri) + .setTitle(sample.name) + .setMimeType(sample.mimeType); + if (sample.drmSchemeUuid != null) { + mediaItemBuilder.setDrmSchemes( + Collections.singletonList( + new MediaItem.DrmScheme( + sample.drmSchemeUuid, new MediaItem.UriBundle(sample.licenseServerUri)))); + } + playerManager.addItem(mediaItemBuilder.build()); mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1); }); return dialogList; @@ -254,19 +294,11 @@ public class MainActivity extends AppCompatActivity } - private static final class SampleListAdapter extends ArrayAdapter { + private static final class SampleListAdapter extends ArrayAdapter { public SampleListAdapter(Context context) { super(context, android.R.layout.simple_list_item_1, DemoUtil.SAMPLES); } - - @Override - public View getView(int position, @Nullable View convertView, ViewGroup parent) { - TextView view = (TextView) super.getView(position, convertView, parent); - MediaItem sample = DemoUtil.SAMPLES.get(position); - view.setText(sample.title); - return view; - } } } diff --git a/demos/cast/src/main/res/layout/cast_context_error_message_layout.xml b/demos/cast/src/main/res/layout/cast_context_error_message_layout.xml new file mode 100644 index 0000000000..6d3260de38 --- /dev/null +++ b/demos/cast/src/main/res/layout/cast_context_error_message_layout.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/demos/cast/src/main/res/values/strings.xml b/demos/cast/src/main/res/values/strings.xml index 3505c40400..58f5233412 100644 --- a/demos/cast/src/main/res/values/strings.xml +++ b/demos/cast/src/main/res/values/strings.xml @@ -22,4 +22,6 @@ Add samples + Failed to get Cast context. Try updating Google Play Services and restart the app. + diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index 65d5096f30..560a9be58a 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.demo; import android.app.Application; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; @@ -72,6 +74,17 @@ public class DemoApplication extends Application { return "withExtensions".equals(BuildConfig.FLAVOR); } + public RenderersFactory buildRenderersFactory(boolean preferExtensionRenderer) { + @DefaultRenderersFactory.ExtensionRendererMode + int extensionRendererMode = + useExtensionRenderers() + ? (preferExtensionRenderer + ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + return new DefaultRenderersFactory(this, extensionRendererMode); + } + public DownloadManager getDownloadManager() { initDownloadManager(); return downloadManager; @@ -88,10 +101,12 @@ public class DemoApplication extends Application { new DownloaderConstructorHelper(getDownloadCache(), buildHttpDataSourceFactory()); downloadManager = new DownloadManager( + this, new File(getDownloadDirectory(), DOWNLOAD_ACTION_FILE), new DefaultDownloaderFactory(downloaderConstructorHelper), MAX_SIMULTANEOUS_DOWNLOADS, - DownloadManager.DEFAULT_MIN_RETRY_COUNT); + DownloadManager.DEFAULT_MIN_RETRY_COUNT, + DownloadManager.DEFAULT_REQUIREMENTS); downloadTracker = new DownloadTracker( /* context= */ this, diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java index 7d1ab16ce4..70cbe43dd8 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java @@ -17,8 +17,8 @@ package com.google.android.exoplayer2.demo; import android.app.Notification; import com.google.android.exoplayer2.offline.DownloadManager; -import com.google.android.exoplayer2.offline.DownloadManager.TaskState; import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.offline.DownloadState; import com.google.android.exoplayer2.scheduler.PlatformScheduler; import com.google.android.exoplayer2.ui.DownloadNotificationUtil; import com.google.android.exoplayer2.util.NotificationUtil; @@ -31,12 +31,15 @@ public class DemoDownloadService extends DownloadService { private static final int JOB_ID = 1; private static final int FOREGROUND_NOTIFICATION_ID = 1; + private static int nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1; + public DemoDownloadService() { super( FOREGROUND_NOTIFICATION_ID, DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, CHANNEL_ID, R.string.exo_download_notification_channel_name); + nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1; } @Override @@ -50,40 +53,38 @@ public class DemoDownloadService extends DownloadService { } @Override - protected Notification getForegroundNotification(TaskState[] taskStates) { + protected Notification getForegroundNotification(DownloadState[] downloadStates) { return DownloadNotificationUtil.buildProgressNotification( /* context= */ this, - R.drawable.exo_controls_play, + R.drawable.ic_download, CHANNEL_ID, /* contentIntent= */ null, /* message= */ null, - taskStates); + downloadStates); } @Override - protected void onTaskStateChanged(TaskState taskState) { - if (taskState.action.isRemoveAction) { - return; - } + protected void onDownloadStateChanged(DownloadState downloadState) { Notification notification = null; - if (taskState.state == TaskState.STATE_COMPLETED) { + if (downloadState.state == DownloadState.STATE_COMPLETED) { notification = DownloadNotificationUtil.buildDownloadCompletedNotification( /* context= */ this, - R.drawable.exo_controls_play, + R.drawable.ic_download_done, CHANNEL_ID, /* contentIntent= */ null, - Util.fromUtf8Bytes(taskState.action.data)); - } else if (taskState.state == TaskState.STATE_FAILED) { + Util.fromUtf8Bytes(downloadState.customMetadata)); + } else if (downloadState.state == DownloadState.STATE_FAILED) { notification = DownloadNotificationUtil.buildDownloadFailedNotification( /* context= */ this, - R.drawable.exo_controls_play, + R.drawable.ic_download_done, CHANNEL_ID, /* contentIntent= */ null, - Util.fromUtf8Bytes(taskState.action.data)); + Util.fromUtf8Bytes(downloadState.customMetadata)); + } else { + return; } - int notificationId = FOREGROUND_NOTIFICATION_ID + 1 + taskState.taskId; - NotificationUtil.setNotification(this, notificationId, notification); + NotificationUtil.setNotification(this, nextNotificationId++, notification); } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index 9c225a21b1..559bbcef0f 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -19,37 +19,44 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; +import android.content.res.Resources; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; +import android.support.annotation.Nullable; +import android.util.Pair; import android.view.LayoutInflater; import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.ListView; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; import android.widget.Toast; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.offline.ActionFile; import com.google.android.exoplayer2.offline.DownloadAction; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.offline.DownloadManager; -import com.google.android.exoplayer2.offline.DownloadManager.TaskState; import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.offline.DownloadState; import com.google.android.exoplayer2.offline.ProgressiveDownloadHelper; import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.offline.TrackKey; -import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.offline.DashDownloadHelper; import com.google.android.exoplayer2.source.hls.offline.HlsDownloadHelper; import com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloadHelper; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.ui.DefaultTrackNameProvider; import com.google.android.exoplayer2.ui.TrackNameProvider; +import com.google.android.exoplayer2.ui.TrackSelectionView; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -114,14 +121,19 @@ public class DownloadTracker implements DownloadManager.Listener { return trackedDownloadStates.get(uri).getKeys(); } - public void toggleDownload(Activity activity, String name, Uri uri, String extension) { + public void toggleDownload( + Activity activity, + String name, + Uri uri, + String extension, + RenderersFactory renderersFactory) { if (isDownloaded(uri)) { - DownloadAction removeAction = getDownloadHelper(uri, extension).getRemoveAction(); + DownloadAction removeAction = + getDownloadHelper(uri, extension, renderersFactory).getRemoveAction(); startServiceWithAction(removeAction); } else { - StartDownloadDialogHelper helper = - new StartDownloadDialogHelper(activity, getDownloadHelper(uri, extension), name); - helper.prepare(); + new StartDownloadDialogHelper( + activity, getDownloadHelper(uri, extension, renderersFactory), name); } } @@ -133,13 +145,11 @@ public class DownloadTracker implements DownloadManager.Listener { } @Override - public void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState) { - DownloadAction action = taskState.action; - Uri uri = action.uri; - if ((action.isRemoveAction && taskState.state == TaskState.STATE_COMPLETED) - || (!action.isRemoveAction && taskState.state == TaskState.STATE_FAILED)) { + public void onDownloadStateChanged(DownloadManager downloadManager, DownloadState downloadState) { + if (downloadState.state == DownloadState.STATE_REMOVED + || downloadState.state == DownloadState.STATE_FAILED) { // A download has been removed, or has failed. Stop tracking it. - if (trackedDownloadStates.remove(uri) != null) { + if (trackedDownloadStates.remove(downloadState.uri) != null) { handleTrackedDownloadStatesChanged(); } } @@ -150,6 +160,14 @@ public class DownloadTracker implements DownloadManager.Listener { // Do nothing. } + @Override + public void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements) { + // Do nothing. + } + // Internal methods private void loadTrackedActions() { @@ -192,15 +210,16 @@ public class DownloadTracker implements DownloadManager.Listener { DownloadService.startWithAction(context, DemoDownloadService.class, action, false); } - private DownloadHelper getDownloadHelper(Uri uri, String extension) { + private DownloadHelper getDownloadHelper( + Uri uri, String extension, RenderersFactory renderersFactory) { int type = Util.inferContentType(uri, extension); switch (type) { case C.TYPE_DASH: - return new DashDownloadHelper(uri, dataSourceFactory); + return new DashDownloadHelper(uri, dataSourceFactory, renderersFactory); case C.TYPE_SS: - return new SsDownloadHelper(uri, dataSourceFactory); + return new SsDownloadHelper(uri, dataSourceFactory, renderersFactory); case C.TYPE_HLS: - return new HlsDownloadHelper(uri, dataSourceFactory); + return new HlsDownloadHelper(uri, dataSourceFactory, renderersFactory); case C.TYPE_OTHER: return new ProgressiveDownloadHelper(uri); default: @@ -208,84 +227,165 @@ public class DownloadTracker implements DownloadManager.Listener { } } + @SuppressWarnings("UngroupedOverloads") private final class StartDownloadDialogHelper - implements DownloadHelper.Callback, DialogInterface.OnClickListener { + implements DownloadHelper.Callback, + DialogInterface.OnClickListener, + View.OnClickListener, + TrackSelectionView.DialogCallback { - private final DownloadHelper downloadHelper; + private final DownloadHelper downloadHelper; private final String name; + private final LayoutInflater dialogInflater; + private final AlertDialog dialog; + private final LinearLayout selectionList; - private final AlertDialog.Builder builder; - private final View dialogView; - private final List trackKeys; - private final ArrayAdapter trackTitles; - private final ListView representationList; + private MappedTrackInfo mappedTrackInfo; + private DefaultTrackSelector.Parameters parameters; - public StartDownloadDialogHelper( - Activity activity, DownloadHelper downloadHelper, String name) { + private StartDownloadDialogHelper( + Activity activity, DownloadHelper downloadHelper, String name) { this.downloadHelper = downloadHelper; this.name = name; - builder = + AlertDialog.Builder builder = new AlertDialog.Builder(activity) - .setTitle(R.string.exo_download_description) + .setTitle(R.string.download_preparing) .setPositiveButton(android.R.string.ok, this) .setNegativeButton(android.R.string.cancel, null); // Inflate with the builder's context to ensure the correct style is used. - LayoutInflater dialogInflater = LayoutInflater.from(builder.getContext()); - dialogView = dialogInflater.inflate(R.layout.start_download_dialog, null); + dialogInflater = LayoutInflater.from(builder.getContext()); + selectionList = (LinearLayout) dialogInflater.inflate(R.layout.start_download_dialog, null); + builder.setView(selectionList); + dialog = builder.create(); + dialog.show(); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - trackKeys = new ArrayList<>(); - trackTitles = - new ArrayAdapter<>( - builder.getContext(), android.R.layout.simple_list_item_multiple_choice); - representationList = dialogView.findViewById(R.id.representation_list); - representationList.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - representationList.setAdapter(trackTitles); - } - - public void prepare() { + parameters = DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS; downloadHelper.prepare(this); } + // DownloadHelper.Callback implementation. + @Override - public void onPrepared(DownloadHelper helper) { - for (int i = 0; i < downloadHelper.getPeriodCount(); i++) { - TrackGroupArray trackGroups = downloadHelper.getTrackGroups(i); - for (int j = 0; j < trackGroups.length; j++) { - TrackGroup trackGroup = trackGroups.get(j); - for (int k = 0; k < trackGroup.length; k++) { - trackKeys.add(new TrackKey(i, j, k)); - trackTitles.add(trackNameProvider.getTrackName(trackGroup.getFormat(k))); - } - } + public void onPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() < 1) { + onPrepareError(downloadHelper, new IOException("Content is empty.")); + return; } - if (!trackKeys.isEmpty()) { - builder.setView(dialogView); - } - builder.create().show(); + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); + updateSelectionList(); + dialog.setTitle(R.string.exo_download_description); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); } @Override - public void onPrepareError(DownloadHelper helper, IOException e) { + public void onPrepareError(DownloadHelper helper, IOException e) { Toast.makeText( context.getApplicationContext(), R.string.download_start_error, Toast.LENGTH_LONG) .show(); Log.e(TAG, "Failed to start download", e); + dialog.cancel(); } + // View.OnClickListener implementation. + + @Override + public void onClick(View v) { + Integer rendererIndex = (Integer) v.getTag(); + String dialogTitle = getTrackTypeString(mappedTrackInfo.getRendererType(rendererIndex)); + Pair dialogPair = + TrackSelectionView.getDialog( + dialog.getContext(), + dialogTitle, + mappedTrackInfo, + rendererIndex, + parameters, + /* callback= */ this); + dialogPair.second.setShowDisableOption(true); + dialogPair.second.setAllowAdaptiveSelections(false); + dialogPair.first.show(); + } + + // TrackSelectionView.DialogCallback implementation. + + @Override + public void onTracksSelected(DefaultTrackSelector.Parameters parameters) { + for (int i = 0; i < downloadHelper.getPeriodCount(); i++) { + downloadHelper.replaceTrackSelections(/* periodIndex= */ i, parameters); + } + this.parameters = parameters; + updateSelectionList(); + } + + // DialogInterface.OnClickListener implementation. + @Override public void onClick(DialogInterface dialog, int which) { - ArrayList selectedTrackKeys = new ArrayList<>(); - for (int i = 0; i < representationList.getChildCount(); i++) { - if (representationList.isItemChecked(i)) { - selectedTrackKeys.add(trackKeys.get(i)); + DownloadAction downloadAction = downloadHelper.getDownloadAction(Util.getUtf8Bytes(name)); + startDownload(downloadAction); + } + + // Internal methods. + + private void updateSelectionList() { + selectionList.removeAllViews(); + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i); + if (trackGroupArray.length == 0) { + continue; + } + String trackTypeString = + getTrackTypeString(mappedTrackInfo.getRendererType(/* rendererIndex= */ i)); + if (trackTypeString == null) { + return; + } + String trackSelectionsString = getTrackSelectionString(/* rendererIndex= */ i); + View view = dialogInflater.inflate(R.layout.download_track_item, selectionList, false); + TextView trackTitleView = view.findViewById(R.id.track_title); + TextView trackDescView = view.findViewById(R.id.track_desc); + ImageButton editButton = view.findViewById(R.id.edit_button); + trackTitleView.setText(trackTypeString); + trackDescView.setText(trackSelectionsString); + editButton.setTag(i); + editButton.setOnClickListener(this); + selectionList.addView(view); + } + } + + private String getTrackSelectionString(int rendererIndex) { + List trackSelections = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, rendererIndex); + String selectedTracks = ""; + Resources resources = selectionList.getResources(); + for (int i = 0; i < trackSelections.size(); i++) { + TrackSelection selection = trackSelections.get(i); + for (int j = 0; j < selection.length(); j++) { + String trackName = trackNameProvider.getTrackName(selection.getFormat(j)); + if (i == 0 && j == 0) { + selectedTracks = trackName; + } else { + selectedTracks = resources.getString(R.string.exo_item_list, selectedTracks, trackName); + } } } - if (!selectedTrackKeys.isEmpty() || trackKeys.isEmpty()) { - // We have selected keys, or we're dealing with single stream content. - DownloadAction downloadAction = - downloadHelper.getDownloadAction(Util.getUtf8Bytes(name), selectedTrackKeys); - startDownload(downloadAction); + return selectedTracks.isEmpty() + ? resources.getString(R.string.exo_track_selection_none) + : selectedTracks; + } + + @Nullable + private String getTrackTypeString(int trackType) { + Resources resources = selectionList.getResources(); + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return resources.getString(R.string.exo_track_selection_title_video); + case C.TRACK_TYPE_AUDIO: + return resources.getString(R.string.exo_track_selection_title_audio); + case C.TRACK_TYPE_TEXT: + return resources.getString(R.string.exo_track_selection_title_text); + default: + return null; } } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index ffa9bafa4f..582638b460 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -35,11 +35,11 @@ import android.widget.TextView; import android.widget.Toast; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C.ContentType; -import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; @@ -48,7 +48,6 @@ import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; import com.google.android.exoplayer2.drm.UnsupportedDrmException; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; -import com.google.android.exoplayer2.offline.FilteringManifestParser; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; @@ -58,11 +57,8 @@ import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistParserFactory; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; @@ -416,13 +412,8 @@ public class PlayerActivity extends Activity boolean preferExtensionDecoders = intent.getBooleanExtra(PREFER_EXTENSION_DECODERS_EXTRA, false); - @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode = - ((DemoApplication) getApplication()).useExtensionRenderers() - ? (preferExtensionDecoders ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; - DefaultRenderersFactory renderersFactory = - new DefaultRenderersFactory(this, extensionRendererMode); + RenderersFactory renderersFactory = + ((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders); trackSelector = new DefaultTrackSelector(trackSelectionFactory); trackSelector.setParameters(trackSelectorParameters); @@ -477,21 +468,19 @@ public class PlayerActivity extends Activity @SuppressWarnings("unchecked") private MediaSource buildMediaSource(Uri uri, @Nullable String overrideExtension) { @ContentType int type = Util.inferContentType(uri, overrideExtension); + List offlineStreamKeys = getOfflineStreamKeys(uri); switch (type) { case C.TYPE_DASH: return new DashMediaSource.Factory(dataSourceFactory) - .setManifestParser( - new FilteringManifestParser<>(new DashManifestParser(), getOfflineStreamKeys(uri))) + .setStreamKeys(offlineStreamKeys) .createMediaSource(uri); case C.TYPE_SS: return new SsMediaSource.Factory(dataSourceFactory) - .setManifestParser( - new FilteringManifestParser<>(new SsManifestParser(), getOfflineStreamKeys(uri))) + .setStreamKeys(offlineStreamKeys) .createMediaSource(uri); case C.TYPE_HLS: return new HlsMediaSource.Factory(dataSourceFactory) - .setPlaylistParserFactory( - new DefaultHlsPlaylistParserFactory(getOfflineStreamKeys(uri))) + .setStreamKeys(offlineStreamKeys) .createMediaSource(uri); case C.TYPE_OTHER: return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri); @@ -534,6 +523,9 @@ public class PlayerActivity extends Activity mediaSource = null; trackSelector = null; } + if (adsLoader != null) { + adsLoader.setPlayer(null); + } releaseMediaDrm(); } @@ -597,6 +589,7 @@ public class PlayerActivity extends Activity // The demo app has a non-null overlay frame layout. playerView.getOverlayFrameLayout().addView(adUiViewGroup); } + adsLoader.setPlayer(player); AdsMediaSource.MediaSourceFactory adMediaSourceFactory = new AdsMediaSource.MediaSourceFactory() { @Override diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 20e27d8d48..5db52fd575 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -37,6 +37,7 @@ import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSourceInputStream; @@ -177,7 +178,11 @@ public class SampleChooserActivity extends Activity .show(); } else { UriSample uriSample = (UriSample) sample; - downloadTracker.toggleDownload(this, sample.name, uriSample.uri, uriSample.extension); + RenderersFactory renderersFactory = + ((DemoApplication) getApplication()) + .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem)); + downloadTracker.toggleDownload( + this, sample.name, uriSample.uri, uriSample.extension, renderersFactory); } } diff --git a/demos/main/src/main/res/drawable-hdpi/ic_edit.png b/demos/main/src/main/res/drawable-hdpi/ic_edit.png new file mode 100755 index 0000000000..25678d6de9 Binary files /dev/null and b/demos/main/src/main/res/drawable-hdpi/ic_edit.png differ diff --git a/demos/main/src/main/res/drawable-mdpi/ic_edit.png b/demos/main/src/main/res/drawable-mdpi/ic_edit.png new file mode 100755 index 0000000000..dffcd9f61a Binary files /dev/null and b/demos/main/src/main/res/drawable-mdpi/ic_edit.png differ diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_edit.png b/demos/main/src/main/res/drawable-xhdpi/ic_edit.png new file mode 100755 index 0000000000..82f8563d1e Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_edit.png differ diff --git a/demos/main/src/main/res/drawable-xxhdpi/ic_edit.png b/demos/main/src/main/res/drawable-xxhdpi/ic_edit.png new file mode 100755 index 0000000000..f00b4b68c5 Binary files /dev/null and b/demos/main/src/main/res/drawable-xxhdpi/ic_edit.png differ diff --git a/demos/main/src/main/res/drawable-xxxhdpi/ic_edit.png b/demos/main/src/main/res/drawable-xxxhdpi/ic_edit.png new file mode 100755 index 0000000000..a9f99417fb Binary files /dev/null and b/demos/main/src/main/res/drawable-xxxhdpi/ic_edit.png differ diff --git a/demos/main/src/main/res/layout/download_track_item.xml b/demos/main/src/main/res/layout/download_track_item.xml new file mode 100644 index 0000000000..fe1c62b391 --- /dev/null +++ b/demos/main/src/main/res/layout/download_track_item.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + diff --git a/demos/main/src/main/res/layout/start_download_dialog.xml b/demos/main/src/main/res/layout/start_download_dialog.xml index acb9af5d97..c182047ff8 100644 --- a/demos/main/src/main/res/layout/start_download_dialog.xml +++ b/demos/main/src/main/res/layout/start_download_dialog.xml @@ -13,7 +13,8 @@ See the License for the specific language governing permissions and limitations under the License. --> - diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index 40f065b18e..7ac5a65a49 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -51,6 +51,10 @@ Playing sample without ads, as the IMA extension was not loaded + Edit selection + + Preparing download… + Failed to start download This demo app does not support downloading playlists diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index f6821d5cd2..0baa074d4a 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -31,7 +31,7 @@ android { } dependencies { - api 'com.google.android.gms:play-services-cast-framework:16.0.3' + api 'com.google.android.gms:play-services-cast-framework:16.1.2' compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion implementation project(modulePrefix + 'library-core') diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 584ac68305..871c28b785 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -266,20 +266,29 @@ public final class CastPlayer extends BasePlayer { // Player implementation. @Override + @Nullable public AudioComponent getAudioComponent() { return null; } @Override + @Nullable public VideoComponent getVideoComponent() { return null; } @Override + @Nullable public TextComponent getTextComponent() { return null; } + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return null; + } + @Override public Looper getApplicationLooper() { return Looper.getMainLooper(); 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 ab10f41d8f..88276c17fe 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 @@ -20,6 +20,7 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.metadata.icy.IcyHeaders; import com.google.android.exoplayer2.upstream.BaseDataSource; import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; @@ -493,6 +494,11 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (dataSpec.httpBody != null && !isContentTypeHeaderSet) { throw new IOException("HTTP request with non-empty body must set Content-Type"); } + if (dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA)) { + requestBuilder.addHeader( + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME, + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE); + } // Set the Range header. if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java index 6f3c623f3f..c5b76002fa 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java @@ -37,6 +37,10 @@ import java.util.List; private static final int OUTPUT_BUFFER_SIZE_16BIT = 65536; private static final int OUTPUT_BUFFER_SIZE_32BIT = OUTPUT_BUFFER_SIZE_16BIT * 2; + // Error codes matching ffmpeg_jni.cc. + private static final int DECODER_ERROR_INVALID_DATA = -1; + private static final int DECODER_ERROR_OTHER = -2; + private final String codecName; private final @Nullable byte[] extraData; private final @C.Encoding int encoding; @@ -106,8 +110,14 @@ import java.util.List; int inputSize = inputData.limit(); ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize); int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize); - if (result < 0) { - return new FfmpegDecoderException("Error decoding (see logcat). Code: " + result); + if (result == DECODER_ERROR_INVALID_DATA) { + // Treat invalid data errors as non-fatal to match the behavior of MediaCodec. No output will + // be produced for this buffer, so mark it as decode-only to ensure that the audio sink's + // position is reset when more audio is produced. + outputBuffer.setFlags(C.BUFFER_FLAG_DECODE_ONLY); + return null; + } else if (result == DECODER_ERROR_OTHER) { + return new FfmpegDecoderException("Error decoding (see logcat)."); } if (!hasOutputFormat) { channelCount = ffmpegGetChannelCount(nativeContext); diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc index 87579ebb9a..dcd4560e4a 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -63,6 +63,10 @@ static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16; // Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT. static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT; +// Error codes matching FfmpegDecoder.java. +static const int DECODER_ERROR_INVALID_DATA = -1; +static const int DECODER_ERROR_OTHER = -2; + /** * Returns the AVCodec with the specified name, or NULL if it is not available. */ @@ -79,7 +83,7 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData, /** * Decodes the packet into the output buffer, returning the number of bytes - * written, or a negative value in the case of an error. + * written, or a negative DECODER_ERROR constant value in the case of an error. */ int decodePacket(AVCodecContext *context, AVPacket *packet, uint8_t *outputBuffer, int outputSize); @@ -238,6 +242,7 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData, context->channels = rawChannelCount; context->channel_layout = av_get_default_channel_layout(rawChannelCount); } + context->err_recognition = AV_EF_IGNORE_ERR; int result = avcodec_open2(context, codec, NULL); if (result < 0) { logError("avcodec_open2", result); @@ -254,7 +259,8 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, result = avcodec_send_packet(context, packet); if (result) { logError("avcodec_send_packet", result); - return result; + return result == AVERROR_INVALIDDATA ? DECODER_ERROR_INVALID_DATA + : DECODER_ERROR_OTHER; } // Dequeue output data until it runs out. diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index c845cb3423..6c0ec05bfb 100644 --- a/extensions/gvr/build.gradle +++ b/extensions/gvr/build.gradle @@ -33,9 +33,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-ui') implementation 'com.android.support:support-annotations:' + supportLibraryVersion - implementation 'com.google.vr:sdk-audio:1.80.0' - implementation 'com.google.vr:sdk-controller:1.80.0' - api 'com.google.vr:sdk-base:1.80.0' + api 'com.google.vr:sdk-base:1.190.0' compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 40950bceef..311752c7ab 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -47,7 +47,6 @@ import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; @@ -74,7 +73,13 @@ import java.util.List; import java.util.Map; import java.util.Set; -/** Loads ads using the IMA SDK. All methods are called on the main thread. */ +/** + * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread. + * + *

The player instance that will play the loaded ads must be set before playback using {@link + * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling + * {@link #release()}. + */ public final class ImaAdsLoader implements Player.EventListener, AdsLoader, @@ -93,9 +98,9 @@ public final class ImaAdsLoader private final Context context; - private @Nullable ImaSdkSettings imaSdkSettings; - private @Nullable AdEventListener adEventListener; - private @Nullable Set adUiElements; + @Nullable private ImaSdkSettings imaSdkSettings; + @Nullable private AdEventListener adEventListener; + @Nullable private Set adUiElements; private int vastLoadTimeoutMs; private int mediaLoadTimeoutMs; private int mediaBitrate; @@ -317,10 +322,11 @@ public final class ImaAdsLoader private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + @Nullable private Player nextPlayer; private Object pendingAdRequestContext; private List supportedMimeTypes; - private EventListener eventListener; - private Player player; + @Nullable private EventListener eventListener; + @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; private int lastVolumePercentage; @@ -526,6 +532,14 @@ public final class ImaAdsLoader // AdsLoader implementation. + @Override + public void setPlayer(@Nullable Player player) { + Assertions.checkState(Looper.getMainLooper() == Looper.myLooper()); + Assertions.checkState( + player == null || player.getApplicationLooper() == Looper.getMainLooper()); + nextPlayer = player; + } + @Override public void setSupportedContentTypes(@C.ContentType int... contentTypes) { List supportedMimeTypes = new ArrayList<>(); @@ -550,9 +564,10 @@ public final class ImaAdsLoader } @Override - public void attachPlayer(ExoPlayer player, EventListener eventListener, ViewGroup adUiViewGroup) { - Assertions.checkArgument(player.getApplicationLooper() == Looper.getMainLooper()); - this.player = player; + public void start(EventListener eventListener, ViewGroup adUiViewGroup) { + Assertions.checkNotNull( + nextPlayer, "Set player using adsLoader.setPlayer before preparing the player."); + player = nextPlayer; this.eventListener = eventListener; lastVolumePercentage = 0; lastAdProgress = null; @@ -576,7 +591,7 @@ public final class ImaAdsLoader } @Override - public void detachPlayer() { + public void stop() { if (adsManager != null && imaPausedContent) { adPlaybackState = adPlaybackState.withAdResumePositionUs( @@ -598,6 +613,8 @@ public final class ImaAdsLoader adsManager.destroy(); adsManager = null; } + adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); + adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; pendingAdLoadError = null; diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java index 85042c4354..bcccd6cec7 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.ext.ima; import android.os.Handler; import android.support.annotation.Nullable; import android.view.ViewGroup; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; @@ -33,7 +32,8 @@ import java.io.IOException; /** * A {@link MediaSource} that inserts ads linearly with a provided content media source. * - * @deprecated Use com.google.android.exoplayer2.source.ads.AdsMediaSource with ImaAdsLoader. + * @deprecated Use {@link com.google.android.exoplayer2.source.ads.AdsMediaSource} with + * ImaAdsLoader. */ @Deprecated public final class ImaAdsMediaSource extends BaseMediaSource implements SourceInfoRefreshListener { @@ -83,12 +83,8 @@ public final class ImaAdsMediaSource extends BaseMediaSource implements SourceIn } @Override - public void prepareSourceInternal( - final ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - adsMediaSource.prepareSource( - player, isTopLevelSource, /* listener= */ this, mediaTransferListener); + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + adsMediaSource.prepareSource(/* listener= */ this, mediaTransferListener); } @Override @@ -97,8 +93,8 @@ public final class ImaAdsMediaSource extends BaseMediaSource implements SourceIn } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { - return adsMediaSource.createPeriod(id, allocator); + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return adsMediaSource.createPeriod(id, allocator, startPositionUs); } @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java index b626a08780..59dfc6473c 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java @@ -64,14 +64,17 @@ import java.util.Set; }; } + @Override public int getVastMediaWidth() { throw new UnsupportedOperationException(); } + @Override public int getVastMediaHeight() { throw new UnsupportedOperationException(); } + @Override public int getVastMediaBitrate() { throw new UnsupportedOperationException(); } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index b0fe731480..0b097f26f0 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -111,7 +111,7 @@ public class ImaAdsLoaderTest { @Test public void testAttachPlayer_setsAdUiViewGroup() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.attachPlayer(fakeExoPlayer, adsLoaderListener, adUiViewGroup); + imaAdsLoader.start(adsLoaderListener, adUiViewGroup); verify(adDisplayContainer, atLeastOnce()).setAdContainer(adUiViewGroup); } @@ -119,7 +119,7 @@ public class ImaAdsLoaderTest { @Test public void testAttachPlayer_updatesAdPlaybackState() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.attachPlayer(fakeExoPlayer, adsLoaderListener, adUiViewGroup); + imaAdsLoader.start(adsLoaderListener, adUiViewGroup); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -131,14 +131,14 @@ public class ImaAdsLoaderTest { public void testAttachAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); - imaAdsLoader.attachPlayer(fakeExoPlayer, adsLoaderListener, adUiViewGroup); + imaAdsLoader.start(adsLoaderListener, adUiViewGroup); } @Test public void testAttachAndCallbacksAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); - imaAdsLoader.attachPlayer(fakeExoPlayer, adsLoaderListener, adUiViewGroup); + imaAdsLoader.start(adsLoaderListener, adUiViewGroup); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); @@ -166,7 +166,7 @@ public class ImaAdsLoaderTest { setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. - imaAdsLoader.attachPlayer(fakeExoPlayer, adsLoaderListener, adUiViewGroup); + imaAdsLoader.start(adsLoaderListener, adUiViewGroup); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); imaAdsLoader.loadAd(TEST_URI.toString()); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); @@ -210,6 +210,7 @@ public class ImaAdsLoaderTest { .setImaFactory(testImaFactory) .setImaSdkSettings(imaSdkSettings) .buildForAdTag(TEST_URI); + imaAdsLoader.setPlayer(fakeExoPlayer); } private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java index b7818546f9..677d3c2ebd 100644 --- a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java +++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java @@ -129,7 +129,7 @@ public final class JobDispatcherScheduler implements Scheduler { Bundle extras = new Bundle(); extras.putString(KEY_SERVICE_ACTION, serviceAction); extras.putString(KEY_SERVICE_PACKAGE, servicePackage); - extras.putInt(KEY_REQUIREMENTS, requirements.getRequirementsData()); + extras.putInt(KEY_REQUIREMENTS, requirements.getRequirements()); builder.setExtras(extras); return builder.build(); diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index de14cbf6d7..b4811f040a 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -67,10 +67,10 @@ import java.util.Map; * *

    *
  • Actions to initiate media playback ({@code PlaybackStateCompat#ACTION_PREPARE_*} and {@code - * PlaybackStateCompat#ACTION_PLAY_*}) can be handled by a {@link PlaybackPreparer} passed - * when calling {@link #setPlayer(Player, PlaybackPreparer, CustomActionProvider...)}. Custom - * actions can be handled by passing one or more {@link CustomActionProvider}s in a similar - * way. + * PlaybackStateCompat#ACTION_PLAY_*}) can be handled by a {@link PlaybackPreparer} passed to + * {@link #setPlaybackPreparer(PlaybackPreparer)}. + *
  • Custom actions can be handled by passing one or more {@link CustomActionProvider}s to + * {@link #setCustomActionProviders(CustomActionProvider...)}. *
  • To enable a media queue and navigation within it, you can set a {@link QueueNavigator} by * calling {@link #setQueueNavigator(QueueNavigator)}. Use of {@link TimelineQueueNavigator} * is recommended for most use cases. @@ -339,21 +339,21 @@ public final class MediaSessionConnector { /** The wrapped {@link MediaSessionCompat}. */ public final MediaSessionCompat mediaSession; - @Nullable private final MediaMetadataProvider mediaMetadataProvider; - private final ExoPlayerEventListener exoPlayerEventListener; - private final MediaSessionCallback mediaSessionCallback; + private final Looper looper; + private final ComponentListener componentListener; private final ArrayList commandReceivers; - private Player player; private ControlDispatcher controlDispatcher; private CustomActionProvider[] customActionProviders; private Map customActionMap; + @Nullable private MediaMetadataProvider mediaMetadataProvider; + @Nullable private Player player; @Nullable private ErrorMessageProvider errorMessageProvider; @Nullable private Pair customError; - private PlaybackPreparer playbackPreparer; - private QueueNavigator queueNavigator; - private QueueEditor queueEditor; - private RatingCallback ratingCallback; + @Nullable private PlaybackPreparer playbackPreparer; + @Nullable private QueueNavigator queueNavigator; + @Nullable private QueueEditor queueEditor; + @Nullable private RatingCallback ratingCallback; private long enabledPlaybackActions; private int rewindMs; @@ -362,82 +362,60 @@ public final class MediaSessionConnector { /** * Creates an instance. * - *

    Equivalent to {@code MediaSessionConnector(mediaSession, new - * DefaultMediaMetadataProvider(mediaSession.getController(), null))}. - * * @param mediaSession The {@link MediaSessionCompat} to connect to. */ public MediaSessionConnector(MediaSessionCompat mediaSession) { - this( - mediaSession, - new DefaultMediaMetadataProvider(mediaSession.getController(), null)); - } - - /** - * Creates an instance. - * - * @param mediaSession The {@link MediaSessionCompat} to connect to. - * @param mediaMetadataProvider A {@link MediaMetadataProvider} for providing a custom metadata - * object to be published to the media session, or {@code null} if metadata shouldn't be - * published. - */ - public MediaSessionConnector( - MediaSessionCompat mediaSession, - @Nullable MediaMetadataProvider mediaMetadataProvider) { this.mediaSession = mediaSession; - this.mediaMetadataProvider = mediaMetadataProvider; - mediaSession.setFlags(BASE_MEDIA_SESSION_FLAGS); - mediaSessionCallback = new MediaSessionCallback(); - exoPlayerEventListener = new ExoPlayerEventListener(); - controlDispatcher = new DefaultControlDispatcher(); - customActionMap = Collections.emptyMap(); + looper = Util.getLooper(); + componentListener = new ComponentListener(); commandReceivers = new ArrayList<>(); + controlDispatcher = new DefaultControlDispatcher(); + customActionProviders = new CustomActionProvider[0]; + customActionMap = Collections.emptyMap(); + mediaMetadataProvider = + new DefaultMediaMetadataProvider( + mediaSession.getController(), /* metadataExtrasPrefix= */ null); enabledPlaybackActions = DEFAULT_PLAYBACK_ACTIONS; rewindMs = DEFAULT_REWIND_MS; fastForwardMs = DEFAULT_FAST_FORWARD_MS; + mediaSession.setFlags(BASE_MEDIA_SESSION_FLAGS); + mediaSession.setCallback(componentListener, new Handler(looper)); } /** * Sets the player to be connected to the media session. Must be called on the same thread that is * used to access the player. * - *

    The order in which any {@link CustomActionProvider}s are passed determines the order of the - * actions published with the playback state of the session. - * * @param player The player to be connected to the {@code MediaSession}, or {@code null} to * disconnect the current player. - * @param playbackPreparer An optional {@link PlaybackPreparer} for preparing the player. - * @param customActionProviders Optional {@link CustomActionProvider}s to publish and handle - * custom actions. */ - public void setPlayer( - @Nullable Player player, - @Nullable PlaybackPreparer playbackPreparer, - CustomActionProvider... customActionProviders) { - Assertions.checkArgument(player == null || player.getApplicationLooper() == Looper.myLooper()); + public void setPlayer(@Nullable Player player) { + Assertions.checkArgument(player == null || player.getApplicationLooper() == looper); if (this.player != null) { - this.player.removeListener(exoPlayerEventListener); - mediaSession.setCallback(null); + this.player.removeListener(componentListener); } - - unregisterCommandReceiver(this.playbackPreparer); this.player = player; - this.playbackPreparer = playbackPreparer; - registerCommandReceiver(playbackPreparer); - - this.customActionProviders = - (player != null && customActionProviders != null) - ? customActionProviders - : new CustomActionProvider[0]; if (player != null) { - Handler handler = new Handler(Util.getLooper()); - mediaSession.setCallback(mediaSessionCallback, handler); - player.addListener(exoPlayerEventListener); + player.addListener(componentListener); } invalidateMediaSessionPlaybackState(); invalidateMediaSessionMetadata(); } + /** + * Sets the {@link PlaybackPreparer}. + * + * @param playbackPreparer The {@link PlaybackPreparer}. + */ + public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { + if (this.playbackPreparer != playbackPreparer) { + unregisterCommandReceiver(this.playbackPreparer); + this.playbackPreparer = playbackPreparer; + registerCommandReceiver(playbackPreparer); + invalidateMediaSessionPlaybackState(); + } + } + /** * Sets the {@link ControlDispatcher}. * @@ -570,6 +548,32 @@ public final class MediaSessionConnector { invalidateMediaSessionPlaybackState(); } + /** + * Sets custom action providers. The order of the {@link CustomActionProvider}s determines the + * order in which the actions are published. + * + * @param customActionProviders The custom action providers, or null to remove all existing custom + * action providers. + */ + public void setCustomActionProviders(@Nullable CustomActionProvider... customActionProviders) { + this.customActionProviders = + customActionProviders == null ? new CustomActionProvider[0] : customActionProviders; + invalidateMediaSessionPlaybackState(); + } + + /** + * Sets a provider of metadata to be published to the media session. + * + * @param mediaMetadataProvider The provider of metadata to publish, or {@code null} if no + * metadata should be published. + */ + public void setMediaMetadataProvider(@Nullable MediaMetadataProvider mediaMetadataProvider) { + if (this.mediaMetadataProvider != mediaMetadataProvider) { + this.mediaMetadataProvider = mediaMetadataProvider; + invalidateMediaSessionMetadata(); + } + } + /** * Updates the metadata of the media session. * @@ -577,9 +581,11 @@ public final class MediaSessionConnector { * changed and the metadata should be updated immediately. */ public final void invalidateMediaSessionMetadata() { - if (mediaMetadataProvider != null && player != null) { - mediaSession.setMetadata(mediaMetadataProvider.getMetadata(player)); - } + MediaMetadataCompat metadata = + mediaMetadataProvider != null && player != null + ? mediaMetadataProvider.getMetadata(player) + : null; + mediaSession.setMetadata(metadata); } /** @@ -591,7 +597,7 @@ public final class MediaSessionConnector { public final void invalidateMediaSessionPlaybackState() { PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder(); if (player == null) { - builder.setActions(/* capabilities= */ 0).setState(PlaybackStateCompat.STATE_NONE, 0, 0, 0); + builder.setActions(buildPrepareActions()).setState(PlaybackStateCompat.STATE_NONE, 0, 0, 0); mediaSession.setPlaybackState(builder.build()); return; } @@ -627,7 +633,7 @@ public final class MediaSessionConnector { Bundle extras = new Bundle(); extras.putFloat(EXTRAS_PITCH, player.getPlaybackParameters().pitch); builder - .setActions(buildPlaybackActions(player)) + .setActions(buildPrepareActions() | buildPlaybackActions(player)) .setActiveQueueItemId(activeQueueItemId) .setBufferedPosition(player.getBufferedPosition()) .setState( @@ -662,6 +668,12 @@ public final class MediaSessionConnector { commandReceivers.remove(commandReceiver); } + private long buildPrepareActions() { + return playbackPreparer == null + ? 0 + : (PlaybackPreparer.ACTIONS & playbackPreparer.getSupportedPrepareActions()); + } + private long buildPlaybackActions(Player player) { boolean enableSeeking = false; boolean enableRewind = false; @@ -688,9 +700,6 @@ public final class MediaSessionConnector { playbackActions &= enabledPlaybackActions; long actions = playbackActions; - if (playbackPreparer != null) { - actions |= (PlaybackPreparer.ACTIONS & playbackPreparer.getSupportedPrepareActions()); - } if (queueNavigator != null) { actions |= (QueueNavigator.ACTIONS & queueNavigator.getSupportedQueueNavigatorActions(player)); @@ -719,8 +728,7 @@ public final class MediaSessionConnector { } private boolean canDispatchToPlaybackPreparer(long action) { - return player != null - && playbackPreparer != null + return playbackPreparer != null && (playbackPreparer.getSupportedPrepareActions() & action) != 0; } @@ -738,6 +746,13 @@ public final class MediaSessionConnector { return player != null && queueEditor != null; } + private void stopPlayerForPrepare(boolean playWhenReady) { + if (player != null) { + player.stop(); + player.setPlayWhenReady(playWhenReady); + } + } + private void rewind(Player player) { if (player.isCurrentWindowSeekable() && rewindMs > 0) { seekTo(player, player.getCurrentPosition() - rewindMs); @@ -865,11 +880,14 @@ public final class MediaSessionConnector { } } - private class ExoPlayerEventListener implements Player.EventListener { + private class ComponentListener extends MediaSessionCompat.Callback + implements Player.EventListener { private int currentWindowIndex; private int currentWindowCount; + // Player.EventListener implementation. + @Override public void onTimelineChanged( Timeline timeline, @Nullable Object manifest, @Player.TimelineChangeReason int reason) { @@ -932,9 +950,8 @@ public final class MediaSessionConnector { public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { invalidateMediaSessionPlaybackState(); } - } - private class MediaSessionCallback extends MediaSessionCompat.Callback { + // MediaSessionCompat.Callback implementation. @Override public void onPlay() { @@ -1058,8 +1075,7 @@ public final class MediaSessionConnector { @Override public void onPrepare() { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE)) { - player.stop(); - player.setPlayWhenReady(false); + stopPlayerForPrepare(/* playWhenReady= */ false); playbackPreparer.onPrepare(); } } @@ -1067,8 +1083,7 @@ public final class MediaSessionConnector { @Override public void onPrepareFromMediaId(String mediaId, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) { - player.stop(); - player.setPlayWhenReady(false); + stopPlayerForPrepare(/* playWhenReady= */ false); playbackPreparer.onPrepareFromMediaId(mediaId, extras); } } @@ -1076,8 +1091,7 @@ public final class MediaSessionConnector { @Override public void onPrepareFromSearch(String query, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) { - player.stop(); - player.setPlayWhenReady(false); + stopPlayerForPrepare(/* playWhenReady= */ false); playbackPreparer.onPrepareFromSearch(query, extras); } } @@ -1085,8 +1099,7 @@ public final class MediaSessionConnector { @Override public void onPrepareFromUri(Uri uri, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) { - player.stop(); - player.setPlayWhenReady(false); + stopPlayerForPrepare(/* playWhenReady= */ false); playbackPreparer.onPrepareFromUri(uri, extras); } } @@ -1094,8 +1107,7 @@ public final class MediaSessionConnector { @Override public void onPlayFromMediaId(String mediaId, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) { - player.stop(); - player.setPlayWhenReady(true); + stopPlayerForPrepare(/* playWhenReady= */ true); playbackPreparer.onPrepareFromMediaId(mediaId, extras); } } @@ -1103,8 +1115,7 @@ public final class MediaSessionConnector { @Override public void onPlayFromSearch(String query, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) { - player.stop(); - player.setPlayWhenReady(true); + stopPlayerForPrepare(/* playWhenReady= */ true); playbackPreparer.onPrepareFromSearch(query, extras); } } @@ -1112,8 +1123,7 @@ public final class MediaSessionConnector { @Override public void onPlayFromUri(Uri uri, Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) { - player.stop(); - player.setPlayWhenReady(true); + stopPlayerForPrepare(/* playWhenReady= */ true); playbackPreparer.onPrepareFromUri(uri, extras); } } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java index b773396198..617b8781f4 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java @@ -22,9 +22,7 @@ import com.google.android.exoplayer2.ControlDispatcher; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.util.RepeatModeUtil; -/** - * Provides a custom action for toggling repeat modes. - */ +/** Provides a custom action for toggling repeat modes. */ public final class RepeatModeActionProvider implements MediaSessionConnector.CustomActionProvider { /** The default repeat toggle modes. */ diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java index 093913fd8c..b92d7a27b7 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java @@ -65,13 +65,6 @@ public final class TimelineQueueEditor * {@link MediaSessionConnector}. */ public interface QueueDataAdapter { - /** - * Gets the {@link MediaDescriptionCompat} for a {@code position}. - * - * @param position The position in the queue for which to provide a description. - * @return A {@link MediaDescriptionCompat}. - */ - MediaDescriptionCompat getMediaDescription(int position); /** * Adds a {@link MediaDescriptionCompat} at the given {@code position}. * diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java index 5d2b37618f..d0047637dd 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java @@ -41,7 +41,7 @@ public abstract class TimelineQueueNavigator implements MediaSessionConnector.Qu private final MediaSessionCompat mediaSession; private final Timeline.Window window; - protected final int maxQueueSize; + private final int maxQueueSize; private long activeQueueItemId; 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 778277fdbc..dd1db8211a 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 @@ -21,6 +21,7 @@ import android.net.Uri; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.metadata.icy.IcyHeaders; import com.google.android.exoplayer2.upstream.BaseDataSource; import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; @@ -263,7 +264,6 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { private Request makeRequest(DataSpec dataSpec) throws HttpDataSourceException { long position = dataSpec.position; long length = dataSpec.length; - boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); HttpUrl url = HttpUrl.parse(dataSpec.uri.toString()); if (url == null) { @@ -293,10 +293,14 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { if (userAgent != null) { builder.addHeader("User-Agent", userAgent); } - - if (!allowGzip) { + if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { builder.addHeader("Accept-Encoding", "identity"); } + if (dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA)) { + builder.addHeader( + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME, + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE); + } RequestBody requestBody = null; if (dataSpec.httpBody != null) { requestBody = RequestBody.create(null, dataSpec.httpBody); diff --git a/extensions/rtmp/README.md b/extensions/rtmp/README.md index b222bdabd9..3863dff965 100644 --- a/extensions/rtmp/README.md +++ b/extensions/rtmp/README.md @@ -39,7 +39,7 @@ either instantiated and injected from application code, or obtained from instances of `DataSource.Factory` that are instantiated and injected from application code. -`DefaultDataSource` will automatically use uses the RTMP extension whenever it's +`DefaultDataSource` will automatically use the RTMP extension whenever it's available. Hence if your application is using `DefaultDataSource` or `DefaultDataSourceFactory`, adding support for RTMP streams is as simple as adding a dependency to the RTMP extension as described above. No changes to your diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java index e3081cd2d2..e61030a2e1 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java @@ -127,8 +127,8 @@ public class LibvpxVideoRenderer extends BaseRenderer { private VpxDecoder decoder; private VpxInputBuffer inputBuffer; private VpxOutputBuffer outputBuffer; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession decoderDrmSession; + @Nullable private DrmSession sourceDrmSession; private @ReinitializationState int decoderReinitializationState; private boolean decoderReceivedBuffers; @@ -364,24 +364,10 @@ public class LibvpxVideoRenderer extends BaseRenderer { clearReportedVideoSize(); clearRenderedFirstFrame(); try { + setSourceDrmSession(null); releaseDecoder(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); - } - } + eventDispatcher.disabled(decoderCounters); } } @@ -433,18 +419,35 @@ public class LibvpxVideoRenderer extends BaseRenderer { /** Releases the decoder. */ @CallSuper protected void releaseDecoder() { - if (decoder == null) { - return; - } - inputBuffer = null; outputBuffer = null; - decoder.release(); - decoder = null; - decoderCounters.decoderReleaseCount++; decoderReinitializationState = REINITIALIZATION_STATE_NONE; decoderReceivedBuffers = false; buffersInCodecCount = 0; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setDecoderDrmSession(@Nullable DrmSession session) { + DrmSession previous = decoderDrmSession; + decoderDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != decoderDrmSession && session != sourceDrmSession) { + drmSessionManager.releaseSession(session); + } } /** @@ -467,16 +470,20 @@ public class LibvpxVideoRenderer extends BaseRenderer { throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), format.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + DrmSession session = + drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); + if (session == decoderDrmSession || session == sourceDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } - if (pendingDrmSession != drmSession) { + if (sourceDrmSession != decoderDrmSession) { if (decoderReceivedBuffers) { // Signal end of stream and wait for any final output buffers before re-initialization. decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; @@ -704,12 +711,13 @@ public class LibvpxVideoRenderer extends BaseRenderer { return; } - drmSession = pendingDrmSession; + setDecoderDrmSession(sourceDrmSession); + ExoMediaCrypto mediaCrypto = null; - if (drmSession != null) { - mediaCrypto = drmSession.getMediaCrypto(); + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); + DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { // Continue for now. We may be able to avoid failure if the session recovers, or if a new // input format causes the session to be replaced before it's used. @@ -922,12 +930,12 @@ public class LibvpxVideoRenderer extends BaseRenderer { } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (decoderDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(decoderDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index fac9818d9e..8810b51000 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -460,8 +460,8 @@ public final class C { /** * Flags which can apply to a buffer containing a media sample. Possible flag values are {@link - * #BUFFER_FLAG_KEY_FRAME}, {@link #BUFFER_FLAG_END_OF_STREAM}, {@link #BUFFER_FLAG_ENCRYPTED} and - * {@link #BUFFER_FLAG_DECODE_ONLY}. + * #BUFFER_FLAG_KEY_FRAME}, {@link #BUFFER_FLAG_END_OF_STREAM}, {@link #BUFFER_FLAG_LAST_SAMPLE}, + * {@link #BUFFER_FLAG_ENCRYPTED} and {@link #BUFFER_FLAG_DECODE_ONLY}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -470,6 +470,7 @@ public final class C { value = { BUFFER_FLAG_KEY_FRAME, BUFFER_FLAG_END_OF_STREAM, + BUFFER_FLAG_LAST_SAMPLE, BUFFER_FLAG_ENCRYPTED, BUFFER_FLAG_DECODE_ONLY }) @@ -482,6 +483,8 @@ public final class C { * Flag for empty buffers that signal that the end of the stream was reached. */ public static final int BUFFER_FLAG_END_OF_STREAM = MediaCodec.BUFFER_FLAG_END_OF_STREAM; + /** Indicates that a buffer is known to contain the last media sample of the stream. */ + public static final int BUFFER_FLAG_LAST_SAMPLE = 1 << 29; // 0x20000000 /** Indicates that a buffer is (at least partially) encrypted. */ public static final int BUFFER_FLAG_ENCRYPTED = 1 << 30; // 0x40000000 /** Indicates that a buffer should be decoded but not rendered. */ @@ -896,6 +899,26 @@ public final class C { */ public static final int COLOR_RANGE_FULL = MediaFormat.COLOR_RANGE_FULL; + /** Video projection types. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Format.NO_VALUE, + PROJECTION_RECTANGULAR, + PROJECTION_EQUIRECTANGULAR, + PROJECTION_CUBEMAP, + PROJECTION_MESH + }) + public @interface Projection {} + /** Conventional rectangular projection. */ + public static final int PROJECTION_RECTANGULAR = 0; + /** Equirectangular spherical projection. */ + public static final int PROJECTION_EQUIRECTANGULAR = 1; + /** Cube map projection. */ + public static final int PROJECTION_CUBEMAP = 2; + /** 3-D mesh projection. */ + public static final int PROJECTION_MESH = 3; + /** * Priority for media playback. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index a56c8e3b90..8736417362 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -139,26 +139,34 @@ import java.util.concurrent.CopyOnWriteArrayList; repeatMode, shuffleModeEnabled, eventHandler, - this, clock); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } @Override + @Nullable public AudioComponent getAudioComponent() { return null; } @Override + @Nullable public VideoComponent getVideoComponent() { return null; } @Override + @Nullable public TextComponent getTextComponent() { return null; } + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return null; + } + @Override public Looper getPlaybackLooper() { return internalPlayer.getPlaybackLooper(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 2a161b79bd..b4549362f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -95,7 +95,6 @@ import java.util.concurrent.atomic.AtomicBoolean; private final HandlerWrapper handler; private final HandlerThread internalPlaybackThread; private final Handler eventHandler; - private final ExoPlayer player; private final Timeline.Window window; private final Timeline.Period period; private final long backBufferDurationUs; @@ -134,7 +133,6 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, Handler eventHandler, - ExoPlayer player, Clock clock) { this.renderers = renderers; this.trackSelector = trackSelector; @@ -145,7 +143,6 @@ import java.util.concurrent.atomic.AtomicBoolean; this.repeatMode = repeatMode; this.shuffleModeEnabled = shuffleModeEnabled; this.eventHandler = eventHandler; - this.player = player; this.clock = clock; this.queue = new MediaPeriodQueue(); @@ -441,11 +438,7 @@ import java.util.concurrent.atomic.AtomicBoolean; loadControl.onPrepared(); this.mediaSource = mediaSource; setState(Player.STATE_BUFFERING); - mediaSource.prepareSource( - player, - /* isTopLevelSource= */ true, - /* listener= */ this, - bandwidthMeter.getTransferListener()); + mediaSource.prepareSource(/* listener= */ this, bandwidthMeter.getTransferListener()); handler.sendEmptyMessage(MSG_DO_SOME_WORK); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index c30fe160c9..36723c5d73 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.9.2"; + public static final String VERSION = "2.9.4"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.9.2"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.9.4"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2009002; + public static final int VERSION_INT = 2009004; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Format.java b/library/core/src/main/java/com/google/android/exoplayer2/Format.java index 3456fc39a2..6c54c07cde 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Format.java @@ -1181,6 +1181,37 @@ public final class Format implements Parcelable { metadata); } + public Format copyWithFrameRate(float frameRate) { + return new Format( + id, + label, + containerMimeType, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + selectionFlags, + language, + accessibilityChannel, + subsampleOffsetUs, + initializationData, + drmInitData, + metadata); + } + public Format copyWithDrmInitData(@Nullable DrmInitData drmInitData) { return new Format( id, @@ -1274,6 +1305,37 @@ public final class Format implements Parcelable { metadata); } + public Format copyWithBitrate(int bitrate) { + return new Format( + id, + label, + containerMimeType, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + selectionFlags, + language, + accessibilityChannel, + subsampleOffsetUs, + initializationData, + drmInitData, + metadata); + } + /** * Returns the number of pixels if this is a video format whose {@link #width} and {@link #height} * are known, or {@link #NO_VALUE} otherwise diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index 7becac7b55..19622c6801 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -89,7 +89,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.info = info; sampleStreams = new SampleStream[rendererCapabilities.length]; mayRetainStreamFlags = new boolean[rendererCapabilities.length]; - mediaPeriod = createMediaPeriod(info.id, mediaSource, allocator); + mediaPeriod = createMediaPeriod(info.id, mediaSource, allocator, info.startPositionUs); } /** @@ -399,8 +399,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Returns a media period corresponding to the given {@code id}. */ private static MediaPeriod createMediaPeriod( - MediaPeriodId id, MediaSource mediaSource, Allocator allocator) { - MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator); + MediaPeriodId id, MediaSource mediaSource, Allocator allocator, long startPositionUs) { + MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator, startPositionUs); if (id.endPositionUs != C.TIME_UNSET && id.endPositionUs != C.TIME_END_OF_SOURCE) { mediaPeriod = new ClippingMediaPeriod( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 16f8aa2878..e3441fb2a7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.C.VideoScalingMode; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.audio.AudioListener; import com.google.android.exoplayer2.audio.AuxEffectInfo; +import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -299,6 +300,24 @@ public interface Player { void removeTextOutput(TextOutput listener); } + /** The metadata component of a {@link Player}. */ + interface MetadataComponent { + + /** + * Adds a {@link MetadataOutput} to receive metadata. + * + * @param output The output to register. + */ + void addMetadataOutput(MetadataOutput output); + + /** + * Removes a {@link MetadataOutput}. + * + * @param output The output to remove. + */ + void removeMetadataOutput(MetadataOutput output); + } + /** * Listener of changes in player state. All methods have no-op default implementations to allow * selective overrides. @@ -533,6 +552,12 @@ public interface Player { @Nullable TextComponent getTextComponent(); + /** + * Returns the component of this player for metadata output, or null if metadata is not supported. + */ + @Nullable + MetadataComponent getMetadataComponent(); + /** * Returns the {@link Looper} associated with the application thread that's used to access the * player and on which player events are received. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 4ca6b51ce2..e498038fde 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -65,7 +65,11 @@ import java.util.concurrent.CopyOnWriteArraySet; */ @TargetApi(16) public class SimpleExoPlayer extends BasePlayer - implements ExoPlayer, Player.AudioComponent, Player.VideoComponent, Player.TextComponent { + implements ExoPlayer, + Player.AudioComponent, + Player.VideoComponent, + Player.TextComponent, + Player.MetadataComponent { /** @deprecated Use {@link com.google.android.exoplayer2.video.VideoListener}. */ @Deprecated @@ -90,25 +94,25 @@ public class SimpleExoPlayer extends BasePlayer private final AudioFocusManager audioFocusManager; - private Format videoFormat; - private Format audioFormat; + @Nullable private Format videoFormat; + @Nullable private Format audioFormat; - private Surface surface; + @Nullable private Surface surface; private boolean ownsSurface; private @C.VideoScalingMode int videoScalingMode; - private SurfaceHolder surfaceHolder; - private TextureView textureView; + @Nullable private SurfaceHolder surfaceHolder; + @Nullable private TextureView textureView; private int surfaceWidth; private int surfaceHeight; - private DecoderCounters videoDecoderCounters; - private DecoderCounters audioDecoderCounters; + @Nullable private DecoderCounters videoDecoderCounters; + @Nullable private DecoderCounters audioDecoderCounters; private int audioSessionId; private AudioAttributes audioAttributes; private float audioVolume; - private MediaSource mediaSource; + @Nullable private MediaSource mediaSource; private List currentCues; - private VideoFrameMetadataListener videoFrameMetadataListener; - private CameraMotionListener cameraMotionListener; + @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; + @Nullable private CameraMotionListener cameraMotionListener; private boolean hasNotifiedFullWrongThreadWarning; /** @@ -243,20 +247,29 @@ public class SimpleExoPlayer extends BasePlayer } @Override + @Nullable public AudioComponent getAudioComponent() { return this; } @Override + @Nullable public VideoComponent getVideoComponent() { return this; } @Override + @Nullable public TextComponent getTextComponent() { return this; } + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return this; + } + /** * Sets the video scaling mode. * @@ -545,30 +558,26 @@ public class SimpleExoPlayer extends BasePlayer setPlaybackParameters(playbackParameters); } - /** - * Returns the video format currently being played, or null if no video is being played. - */ + /** Returns the video format currently being played, or null if no video is being played. */ + @Nullable public Format getVideoFormat() { return videoFormat; } - /** - * Returns the audio format currently being played, or null if no audio is being played. - */ + /** Returns the audio format currently being played, or null if no audio is being played. */ + @Nullable public Format getAudioFormat() { return audioFormat; } - /** - * Returns {@link DecoderCounters} for video, or null if no video is being played. - */ + /** Returns {@link DecoderCounters} for video, or null if no video is being played. */ + @Nullable public DecoderCounters getVideoDecoderCounters() { return videoDecoderCounters; } - /** - * Returns {@link DecoderCounters} for audio, or null if no audio is being played. - */ + /** Returns {@link DecoderCounters} for audio, or null if no audio is being played. */ + @Nullable public DecoderCounters getAudioDecoderCounters() { return audioDecoderCounters; } @@ -713,20 +722,12 @@ public class SimpleExoPlayer extends BasePlayer removeTextOutput(output); } - /** - * Adds a {@link MetadataOutput} to receive metadata. - * - * @param listener The output to register. - */ + @Override public void addMetadataOutput(MetadataOutput listener) { metadataOutputs.add(listener); } - /** - * Removes a {@link MetadataOutput}. - * - * @param listener The output to remove. - */ + @Override public void removeMetadataOutput(MetadataOutput listener) { metadataOutputs.remove(listener); } @@ -1048,7 +1049,8 @@ public class SimpleExoPlayer extends BasePlayer } @Override - public @Nullable Object getCurrentManifest() { + @Nullable + public Object getCurrentManifest() { verifyApplicationThread(); return player.getCurrentManifest(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 113add612a..55031e2d12 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -488,7 +488,10 @@ public class AnalyticsCollector @Override public final void onPlayerError(ExoPlaybackException error) { - EventTime eventTime = generatePlayingMediaPeriodEventTime(); + EventTime eventTime = + error.type == ExoPlaybackException.TYPE_SOURCE + ? generateLoadingMediaPeriodEventTime() + : generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onPlayerError(eventTime, error); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java index eff7bc8de2..48fbea75b4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -147,6 +147,7 @@ public interface AudioRendererEventListener { * Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. */ public void disabled(final DecoderCounters counters) { + counters.ensureUpdated(); if (listener != null) { handler.post( () -> { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 49c391c4cc..7fc6c16db8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -548,7 +548,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media try { super.onDisabled(); } finally { - decoderCounters.ensureUpdated(); eventDispatcher.disabled(decoderCounters); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java index 287cae9d41..f2e8a23811 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -106,8 +106,8 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements ? extends AudioDecoderException> decoder; private DecoderInputBuffer inputBuffer; private SimpleOutputBuffer outputBuffer; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession decoderDrmSession; + @Nullable private DrmSession sourceDrmSession; @ReinitializationState private int decoderReinitializationState; private boolean decoderReceivedBuffers; @@ -366,7 +366,10 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements if (outputBuffer == null) { return false; } - decoderCounters.skippedOutputBufferCount += outputBuffer.skippedOutputBufferCount; + if (outputBuffer.skippedOutputBufferCount > 0) { + decoderCounters.skippedOutputBufferCount += outputBuffer.skippedOutputBufferCount; + audioSink.handleDiscontinuity(); + } } if (outputBuffer.isEndOfStream()) { @@ -459,12 +462,12 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (decoderDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(decoderDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } @@ -565,25 +568,11 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements audioTrackNeedsConfigure = true; waitingForKeys = false; try { + setSourceDrmSession(null); releaseDecoder(); audioSink.reset(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); - } - } + eventDispatcher.disabled(decoderCounters); } } @@ -612,12 +601,13 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return; } - drmSession = pendingDrmSession; + setDecoderDrmSession(sourceDrmSession); + ExoMediaCrypto mediaCrypto = null; - if (drmSession != null) { - mediaCrypto = drmSession.getMediaCrypto(); + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); + DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { // Continue for now. We may be able to avoid failure if the session recovers, or if a new // input format causes the session to be replaced before it's used. @@ -643,17 +633,34 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } private void releaseDecoder() { - if (decoder == null) { - return; - } - inputBuffer = null; outputBuffer = null; - decoder.release(); - decoder = null; - decoderCounters.decoderReleaseCount++; decoderReinitializationState = REINITIALIZATION_STATE_NONE; decoderReceivedBuffers = false; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setDecoderDrmSession(@Nullable DrmSession session) { + DrmSession previous = decoderDrmSession; + decoderDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != decoderDrmSession && session != sourceDrmSession) { + drmSessionManager.releaseSession(session); + } } private void onInputFormatChanged(Format newFormat) throws ExoPlaybackException { @@ -668,13 +675,16 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), - inputFormat.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + DrmSession session = + drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); + if (session == decoderDrmSession || session == sourceDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java new file mode 100644 index 0000000000..2bb5f260ba --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2018 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.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +/** + * Provides {@link SQLiteDatabase} instances to ExoPlayer components, which may read and write + * tables prefixed with {@link #TABLE_PREFIX}. + */ +public interface DatabaseProvider { + + /** Prefix for tables that can be read and written by ExoPlayer components. */ + String TABLE_PREFIX = "ExoPlayer"; + + /** + * Creates and/or opens a database that will be used for reading and writing. + * + *

    Once opened successfully, the database is cached, so you can call this method every time you + * need to write to the database. Errors such as bad permissions or a full disk may cause this + * method to fail, but future attempts may succeed if the problem is fixed. + * + * @throws SQLiteException If the database cannot be opened for writing. + * @return A read/write database object. + */ + SQLiteDatabase getWritableDatabase(); + + /** + * Creates and/or opens a database. This will be the same object returned by {@link + * #getWritableDatabase()} unless some problem, such as a full disk, requires the database to be + * opened read-only. In that case, a read-only database object will be returned. If the problem is + * fixed, a future call to {@link #getWritableDatabase()} may succeed, in which case the read-only + * database object will be closed and the read/write object will be returned in the future. + * + *

    Once opened successfully, the database is cached, so you can call this method every time you + * need to read from the database. + * + * @throws SQLiteException If the database cannot be opened. + * @return A database object valid until {@link #getWritableDatabase()} is called. + */ + SQLiteDatabase getReadableDatabase(); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java new file mode 100644 index 0000000000..c04683b434 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2018 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.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +/** A {@link DatabaseProvider} that provides instances obtained from a {@link SQLiteOpenHelper}. */ +public final class DefaultDatabaseProvider implements DatabaseProvider { + + private final SQLiteOpenHelper sqliteOpenHelper; + + /** + * @param sqliteOpenHelper An {@link SQLiteOpenHelper} from which to obtain database instances. + */ + public DefaultDatabaseProvider(SQLiteOpenHelper sqliteOpenHelper) { + this.sqliteOpenHelper = sqliteOpenHelper; + } + + @Override + public SQLiteDatabase getWritableDatabase() { + return sqliteOpenHelper.getWritableDatabase(); + } + + @Override + public SQLiteDatabase getReadableDatabase() { + return sqliteOpenHelper.getReadableDatabase(); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java new file mode 100644 index 0000000000..e5bdfbb499 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2018 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.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import com.google.android.exoplayer2.util.Log; + +/** + * An {@link SQLiteOpenHelper} that provides instances of a standalone ExoPlayer database. + * + *

    Suitable for use by applications that do not already have their own database, or which would + * prefer to keep ExoPlayer tables isolated in their own database. Other applications should prefer + * to use {@link DefaultDatabaseProvider} with their own {@link SQLiteOpenHelper}. + */ +public final class ExoDatabaseProvider extends SQLiteOpenHelper implements DatabaseProvider { + + /** The file name used for the standalone ExoPlayer database. */ + public static final String DATABASE_NAME = "exoplayer_internal.db"; + + private static final int VERSION = 1; + private static final String TAG = "ExoDatabaseProvider"; + + public ExoDatabaseProvider(Context context) { + super(context.getApplicationContext(), DATABASE_NAME, /* factory= */ null, VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + // Features create their own tables. + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // Features handle their own upgrades. + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + wipeDatabase(db); + } + + /** + * Makes a best effort to wipe the existing database. The wipe may be incomplete if the database + * contains foreign key constraints. + */ + private static void wipeDatabase(SQLiteDatabase db) { + String[] columns = {"type", "name"}; + try (Cursor cursor = + db.query( + "sqlite_master", + columns, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + while (cursor.moveToNext()) { + String type = cursor.getString(0); + String name = cursor.getString(1); + if (!"sqlite_sequence".equals(name)) { + // If it's not an SQL-controlled entity, drop it + String sql = "DROP " + type + " IF EXISTS " + name; + try { + db.execSQL(sql); + } catch (SQLException e) { + Log.e(TAG, "Error executing " + sql, e); + } + } + } + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java new file mode 100644 index 0000000000..0b6ef3d816 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2018 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.database; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A table that holds version information about other ExoPlayer tables. This allows ExoPlayer tables + * to be versioned independently to the version of the containing database. + */ +public final class VersionTable { + + /** Returned by {@link #getVersion(int)} if the version is unset. */ + public static final int VERSION_UNSET = -1; + /** Version of tables used for offline functionality. */ + public static final int FEATURE_OFFLINE = 0; + /** Version of tables used for cache functionality. */ + public static final int FEATURE_CACHE = 1; + + private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "Versions"; + + private static final String COLUMN_FEATURE = "feature"; + private static final String COLUMN_VERSION = "version"; + + private static final String SQL_CREATE_TABLE_IF_NOT_EXISTS = + "CREATE TABLE IF NOT EXISTS " + + TABLE_NAME + + " (" + + COLUMN_FEATURE + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_VERSION + + " INTEGER NOT NULL)"; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FEATURE_OFFLINE, FEATURE_CACHE}) + private @interface Feature {} + + private final DatabaseProvider databaseProvider; + + public VersionTable(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + // Check whether the table exists to avoid getting a writable database if we don't need one. + if (!doesTableExist(databaseProvider, TABLE_NAME)) { + databaseProvider.getWritableDatabase().execSQL(SQL_CREATE_TABLE_IF_NOT_EXISTS); + } + } + + /** + * Sets the version of tables belonging to the specified feature. + * + * @param feature The feature. + * @param version The version. + */ + public void setVersion(@Feature int feature, int version) { + ContentValues values = new ContentValues(); + values.put(COLUMN_FEATURE, feature); + values.put(COLUMN_VERSION, version); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + + /** + * Returns the version of tables belonging to the specified feature, or {@link #VERSION_UNSET} if + * no version information is available. + */ + public int getVersion(@Feature int feature) { + String selection = COLUMN_FEATURE + " = ?"; + String[] selectionArgs = {Integer.toString(feature)}; + try (Cursor cursor = + databaseProvider + .getReadableDatabase() + .query( + TABLE_NAME, + new String[] {COLUMN_VERSION}, + selection, + selectionArgs, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + if (cursor.getCount() == 0) { + return VERSION_UNSET; + } + cursor.moveToNext(); + return cursor.getInt(/* COLUMN_VERSION index */ 0); + } + } + + /* package */ static boolean doesTableExist(DatabaseProvider databaseProvider, String tableName) { + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + long count = + DatabaseUtils.queryNumEntries( + readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); + return count > 0; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java index d5a4f6add5..feba7eaaf4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java @@ -15,14 +15,5 @@ */ package com.google.android.exoplayer2.drm; -/** - * An opaque {@link android.media.MediaCrypto} equivalent. - */ -public interface ExoMediaCrypto { - - /** - * @see android.media.MediaCrypto#requiresSecureDecoderComponent(String) - */ - boolean requiresSecureDecoderComponent(String mimeType); - -} +/** An opaque {@link android.media.MediaCrypto} equivalent. */ +public interface ExoMediaCrypto {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java index 24c3ddbbd0..aca56139de 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -265,11 +265,9 @@ public interface ExoMediaDrm { /** * @see android.media.MediaCrypto#MediaCrypto(UUID, byte[]) - * - * @param initData Opaque initialization data specific to the crypto scheme. + * @param sessionId The DRM session ID. * @return An object extends {@link ExoMediaCrypto}, using opaque crypto scheme specific data. * @throws MediaCryptoException If the instance can't be created. */ - T createMediaCrypto(byte[] initData) throws MediaCryptoException; - + T createMediaCrypto(byte[] sessionId) throws MediaCryptoException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java index 4e58ed6a31..156138ab9b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java @@ -17,48 +17,35 @@ package com.google.android.exoplayer2.drm; import android.annotation.TargetApi; import android.media.MediaCrypto; -import com.google.android.exoplayer2.util.Assertions; +import java.util.UUID; /** - * An {@link ExoMediaCrypto} implementation that wraps the framework {@link MediaCrypto}. + * An {@link ExoMediaCrypto} implementation that contains the necessary information to build or + * update a framework {@link MediaCrypto}. */ @TargetApi(16) public final class FrameworkMediaCrypto implements ExoMediaCrypto { - private final MediaCrypto mediaCrypto; - private final boolean forceAllowInsecureDecoderComponents; + /** The DRM scheme UUID. */ + public final UUID uuid; + /** The DRM session id. */ + public final byte[] sessionId; + /** + * Whether to allow use of insecure decoder components even if the underlying platform says + * otherwise. + */ + public final boolean forceAllowInsecureDecoderComponents; /** - * @param mediaCrypto The {@link MediaCrypto} to wrap. + * @param uuid The DRM scheme UUID. + * @param sessionId The DRM session id. + * @param forceAllowInsecureDecoderComponents Whether to allow use of insecure decoder components + * even if the underlying platform says otherwise. */ - public FrameworkMediaCrypto(MediaCrypto mediaCrypto) { - this(mediaCrypto, false); - } - - /** - * @param mediaCrypto The {@link MediaCrypto} to wrap. - * @param forceAllowInsecureDecoderComponents Whether to force - * {@link #requiresSecureDecoderComponent(String)} to return {@code false}, rather than - * {@link MediaCrypto#requiresSecureDecoderComponent(String)} of the wrapped - * {@link MediaCrypto}. - */ - public FrameworkMediaCrypto(MediaCrypto mediaCrypto, - boolean forceAllowInsecureDecoderComponents) { - this.mediaCrypto = Assertions.checkNotNull(mediaCrypto); + public FrameworkMediaCrypto( + UUID uuid, byte[] sessionId, boolean forceAllowInsecureDecoderComponents) { + this.uuid = uuid; + this.sessionId = sessionId; this.forceAllowInsecureDecoderComponents = forceAllowInsecureDecoderComponents; } - - /** - * Returns the wrapped {@link MediaCrypto}. - */ - public MediaCrypto getWrappedMediaCrypto() { - return mediaCrypto; - } - - @Override - public boolean requiresSecureDecoderComponent(String mimeType) { - return !forceAllowInsecureDecoderComponents - && mediaCrypto.requiresSecureDecoderComponent(mimeType); - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index fda85a759c..b139288f98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.media.DeniedByServerException; -import android.media.MediaCrypto; import android.media.MediaCryptoException; import android.media.MediaDrm; import android.media.MediaDrmException; @@ -210,7 +209,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm schemeDatas) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/MpegAudioHeader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/MpegAudioHeader.java index ab49ca5454..87bb992082 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/MpegAudioHeader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/MpegAudioHeader.java @@ -34,16 +34,26 @@ public final class MpegAudioHeader { private static final String[] MIME_TYPE_BY_LAYER = new String[] {MimeTypes.AUDIO_MPEG_L1, MimeTypes.AUDIO_MPEG_L2, MimeTypes.AUDIO_MPEG}; private static final int[] SAMPLING_RATE_V1 = {44100, 48000, 32000}; - private static final int[] BITRATE_V1_L1 = - {32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448}; - private static final int[] BITRATE_V2_L1 = - {32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256}; - private static final int[] BITRATE_V1_L2 = - {32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384}; - private static final int[] BITRATE_V1_L3 = - {32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320}; - private static final int[] BITRATE_V2 = - {8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160}; + private static final int[] BITRATE_V1_L1 = { + 32000, 64000, 96000, 128000, 160000, 192000, 224000, 256000, 288000, 320000, 352000, 384000, + 416000, 448000 + }; + private static final int[] BITRATE_V2_L1 = { + 32000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 144000, 160000, 176000, 192000, + 224000, 256000 + }; + private static final int[] BITRATE_V1_L2 = { + 32000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, + 320000, 384000 + }; + private static final int[] BITRATE_V1_L3 = { + 32000, 40000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, + 320000 + }; + private static final int[] BITRATE_V2 = { + 8000, 16000, 24000, 32000, 40000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 144000, + 160000 + }; /** * Returns the size of the frame associated with {@code header}, or {@link C#LENGTH_UNSET} if it @@ -89,7 +99,7 @@ public final class MpegAudioHeader { if (layer == 3) { // Layer I (layer == 3) bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; - return (12000 * bitrate / samplingRate + padding) * 4; + return (12 * bitrate / samplingRate + padding) * 4; } else { // Layer II (layer == 2) or III (layer == 1) if (version == 3) { @@ -102,10 +112,10 @@ public final class MpegAudioHeader { if (version == 3) { // Version 1 - return 144000 * bitrate / samplingRate + padding; + return 144 * bitrate / samplingRate + padding; } else { // Version 2 or 2.5 - return (layer == 1 ? 72000 : 144000) * bitrate / samplingRate + padding; + return (layer == 1 ? 72 : 144) * bitrate / samplingRate + padding; } } @@ -159,7 +169,7 @@ public final class MpegAudioHeader { if (layer == 3) { // Layer I (layer == 3) bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; - frameSize = (12000 * bitrate / sampleRate + padding) * 4; + frameSize = (12 * bitrate / sampleRate + padding) * 4; samplesPerFrame = 384; } else { // Layer II (layer == 2) or III (layer == 1) @@ -167,19 +177,22 @@ public final class MpegAudioHeader { // Version 1 bitrate = layer == 2 ? BITRATE_V1_L2[bitrateIndex - 1] : BITRATE_V1_L3[bitrateIndex - 1]; samplesPerFrame = 1152; - frameSize = 144000 * bitrate / sampleRate + padding; + frameSize = 144 * bitrate / sampleRate + padding; } else { // Version 2 or 2.5. bitrate = BITRATE_V2[bitrateIndex - 1]; samplesPerFrame = layer == 1 ? 576 : 1152; - frameSize = (layer == 1 ? 72000 : 144000) * bitrate / sampleRate + padding; + frameSize = (layer == 1 ? 72 : 144) * bitrate / sampleRate + padding; } } + // Calculate the bitrate in the same way Mp3Extractor calculates sample timestamps so that + // seeking to a given timestamp and playing from the start up to that timestamp give the same + // results for CBR streams. See also [internal: b/120390268]. + bitrate = 8 * frameSize * sampleRate / samplesPerFrame; String mimeType = MIME_TYPE_BY_LAYER[3 - layer]; int channels = ((headerData >> 6) & 3) == 3 ? 1 : 2; - header.setValues(version, mimeType, frameSize, sampleRate, channels, bitrate * 1000, - samplesPerFrame); + header.setValues(version, mimeType, frameSize, sampleRate, channels, bitrate, samplesPerFrame); return true; } @@ -198,8 +211,14 @@ public final class MpegAudioHeader { /** Number of samples stored in the frame. */ public int samplesPerFrame; - private void setValues(int version, String mimeType, int frameSize, int sampleRate, int channels, - int bitrate, int samplesPerFrame) { + private void setValues( + int version, + String mimeType, + int frameSize, + int sampleRate, + int channels, + int bitrate, + int samplesPerFrame) { this.version = version; this.mimeType = mimeType; this.frameSize = frameSize; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 86b750e821..187b9ae443 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -191,7 +191,11 @@ public final class MatroskaExtractor implements Extractor { private static final int ID_CUE_CLUSTER_POSITION = 0xF1; private static final int ID_LANGUAGE = 0x22B59C; private static final int ID_PROJECTION = 0x7670; + private static final int ID_PROJECTION_TYPE = 0x7671; private static final int ID_PROJECTION_PRIVATE = 0x7672; + private static final int ID_PROJECTION_POSE_YAW = 0x7673; + private static final int ID_PROJECTION_POSE_PITCH = 0x7674; + private static final int ID_PROJECTION_POSE_ROLL = 0x7675; private static final int ID_STEREO_MODE = 0x53B8; private static final int ID_COLOUR = 0x55B0; private static final int ID_COLOUR_RANGE = 0x55B9; @@ -760,6 +764,24 @@ public final class MatroskaExtractor implements Extractor { case ID_MAX_FALL: currentTrack.maxFrameAverageLuminance = (int) value; break; + case ID_PROJECTION_TYPE: + switch ((int) value) { + case 0: + currentTrack.projectionType = C.PROJECTION_RECTANGULAR; + break; + case 1: + currentTrack.projectionType = C.PROJECTION_EQUIRECTANGULAR; + break; + case 2: + currentTrack.projectionType = C.PROJECTION_CUBEMAP; + break; + case 3: + currentTrack.projectionType = C.PROJECTION_MESH; + break; + default: + break; + } + break; default: break; } @@ -803,6 +825,15 @@ public final class MatroskaExtractor implements Extractor { case ID_LUMNINANCE_MIN: currentTrack.minMasteringLuminance = (float) value; break; + case ID_PROJECTION_POSE_YAW: + currentTrack.projectionPoseYaw = (float) value; + break; + case ID_PROJECTION_POSE_PITCH: + currentTrack.projectionPosePitch = (float) value; + break; + case ID_PROJECTION_POSE_ROLL: + currentTrack.projectionPoseRoll = (float) value; + break; default: break; } @@ -1465,6 +1496,7 @@ public final class MatroskaExtractor implements Extractor { case ID_COLOUR_PRIMARIES: case ID_MAX_CLL: case ID_MAX_FALL: + case ID_PROJECTION_TYPE: return TYPE_UNSIGNED_INT; case ID_DOC_TYPE: case ID_NAME: @@ -1491,6 +1523,9 @@ public final class MatroskaExtractor implements Extractor { case ID_WHITE_POINT_CHROMATICITY_Y: case ID_LUMNINANCE_MAX: case ID_LUMNINANCE_MIN: + case ID_PROJECTION_POSE_YAW: + case ID_PROJECTION_POSE_PITCH: + case ID_PROJECTION_POSE_ROLL: return TYPE_FLOAT; default: return TYPE_UNKNOWN; @@ -1631,6 +1666,10 @@ public final class MatroskaExtractor implements Extractor { public int displayWidth = Format.NO_VALUE; public int displayHeight = Format.NO_VALUE; public int displayUnit = DISPLAY_UNIT_PIXELS; + @C.Projection public int projectionType = Format.NO_VALUE; + public float projectionPoseYaw = 0f; + public float projectionPosePitch = 0f; + public float projectionPoseRoll = 0f; public byte[] projectionData = null; @C.StereoMode public int stereoMode = Format.NO_VALUE; @@ -1850,6 +1889,21 @@ public final class MatroskaExtractor implements Extractor { } else if ("htc_video_rotA-270".equals(name)) { rotationDegrees = 270; } + if (projectionType == C.PROJECTION_RECTANGULAR + && Float.compare(projectionPoseYaw, 0f) == 0 + && Float.compare(projectionPosePitch, 0f) == 0) { + // The range of projectionPoseRoll is [-180, 180]. + if (Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0; + } else if (Float.compare(projectionPosePitch, 90f) == 0) { + rotationDegrees = 90; + } else if (Float.compare(projectionPosePitch, -180f) == 0 + || Float.compare(projectionPosePitch, 180f) == 0) { + rotationDegrees = 180; + } else if (Float.compare(projectionPosePitch, -90f) == 0) { + rotationDegrees = 270; + } + } format = Format.createVideoSampleFormat( Integer.toString(trackId), diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index 440e577c7d..8d78337617 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -22,7 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -@SuppressWarnings("ConstantField") +@SuppressWarnings({"ConstantField", "ConstantCaseForConstants"}) /* package */ abstract class Atom { /** @@ -130,6 +130,7 @@ import java.util.List; public static final int TYPE_sawb = Util.getIntegerCodeForString("sawb"); public static final int TYPE_udta = Util.getIntegerCodeForString("udta"); public static final int TYPE_meta = Util.getIntegerCodeForString("meta"); + public static final int TYPE_keys = Util.getIntegerCodeForString("keys"); public static final int TYPE_ilst = Util.getIntegerCodeForString("ilst"); public static final int TYPE_mean = Util.getIntegerCodeForString("mean"); public static final int TYPE_name = Util.getIntegerCodeForString("name"); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index d085156f2b..008a155d1f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.extractor.mp4; import static com.google.android.exoplayer2.util.MimeTypes.getMimeTypeFromMp4ObjectType; +import android.support.annotation.Nullable; import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -39,7 +40,7 @@ import java.util.Collections; import java.util.List; /** Utility methods for parsing MP4 format atom payloads according to ISO 14496-12. */ -@SuppressWarnings("ConstantField") +@SuppressWarnings({"ConstantField", "ConstantCaseForConstants"}) /* package */ final class AtomParsers { private static final String TAG = "AtomParsers"; @@ -51,6 +52,7 @@ import java.util.List; private static final int TYPE_subt = Util.getIntegerCodeForString("subt"); private static final int TYPE_clcp = Util.getIntegerCodeForString("clcp"); private static final int TYPE_meta = Util.getIntegerCodeForString("meta"); + private static final int TYPE_mdta = Util.getIntegerCodeForString("mdta"); /** * The threshold number of samples to trim from the start/end of an audio track when applying an @@ -77,7 +79,7 @@ import java.util.List; DrmInitData drmInitData, boolean ignoreEditLists, boolean isQuickTime) throws ParserException { Atom.ContainerAtom mdia = trak.getContainerAtomOfType(Atom.TYPE_mdia); - int trackType = parseHdlr(mdia.getLeafAtomOfType(Atom.TYPE_hdlr).data); + int trackType = getTrackTypeForHdlr(parseHdlr(mdia.getLeafAtomOfType(Atom.TYPE_hdlr).data)); if (trackType == C.TRACK_TYPE_UNKNOWN) { return null; } @@ -485,6 +487,7 @@ import java.util.List; * @param isQuickTime True for QuickTime media. False otherwise. * @return Parsed metadata, or null. */ + @Nullable public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { if (isQuickTime) { // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and @@ -499,14 +502,69 @@ import java.util.List; int atomType = udtaData.readInt(); if (atomType == Atom.TYPE_meta) { udtaData.setPosition(atomPosition); - return parseMetaAtom(udtaData, atomPosition + atomSize); + return parseUdtaMeta(udtaData, atomPosition + atomSize); } - udtaData.skipBytes(atomSize - Atom.HEADER_SIZE); + udtaData.setPosition(atomPosition + atomSize); } return null; } - private static Metadata parseMetaAtom(ParsableByteArray meta, int limit) { + /** + * Parses a metadata meta atom if it contains metadata with handler 'mdta'. + * + * @param meta The metadata atom to decode. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { + Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); + Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); + Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); + if (hdlrAtom == null + || keysAtom == null + || ilstAtom == null + || AtomParsers.parseHdlr(hdlrAtom.data) != TYPE_mdta) { + // There isn't enough information to parse the metadata, or the handler type is unexpected. + return null; + } + + // Parse metadata keys. + ParsableByteArray keys = keysAtom.data; + keys.setPosition(Atom.FULL_HEADER_SIZE); + int entryCount = keys.readInt(); + String[] keyNames = new String[entryCount]; + for (int i = 0; i < entryCount; i++) { + int entrySize = keys.readInt(); + keys.skipBytes(4); // keyNamespace + int keySize = entrySize - 8; + keyNames[i] = keys.readString(keySize); + } + + // Parse metadata items. + ParsableByteArray ilst = ilstAtom.data; + ilst.setPosition(Atom.HEADER_SIZE); + ArrayList entries = new ArrayList<>(); + while (ilst.bytesLeft() > Atom.HEADER_SIZE) { + int atomPosition = ilst.getPosition(); + int atomSize = ilst.readInt(); + int keyIndex = ilst.readInt() - 1; + if (keyIndex >= 0 && keyIndex < keyNames.length) { + String key = keyNames[keyIndex]; + Metadata.Entry entry = + MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); + if (entry != null) { + entries.add(entry); + } + } else { + Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); + } + ilst.setPosition(atomPosition + atomSize); + } + return entries.isEmpty() ? null : new Metadata(entries); + } + + @Nullable + private static Metadata parseUdtaMeta(ParsableByteArray meta, int limit) { meta.skipBytes(Atom.FULL_HEADER_SIZE); while (meta.getPosition() < limit) { int atomPosition = meta.getPosition(); @@ -516,11 +574,12 @@ import java.util.List; meta.setPosition(atomPosition); return parseIlst(meta, atomPosition + atomSize); } - meta.skipBytes(atomSize - Atom.HEADER_SIZE); + meta.setPosition(atomPosition + atomSize); } return null; } + @Nullable private static Metadata parseIlst(ParsableByteArray ilst, int limit) { ilst.skipBytes(Atom.HEADER_SIZE); ArrayList entries = new ArrayList<>(); @@ -610,19 +669,22 @@ import java.util.List; * Parses an hdlr atom. * * @param hdlr The hdlr atom to decode. - * @return The track type. + * @return The handler value. */ private static int parseHdlr(ParsableByteArray hdlr) { hdlr.setPosition(Atom.FULL_HEADER_SIZE + 4); - int trackType = hdlr.readInt(); - if (trackType == TYPE_soun) { + return hdlr.readInt(); + } + + /** Returns the track type for a given handler value. */ + private static int getTrackTypeForHdlr(int hdlr) { + if (hdlr == TYPE_soun) { return C.TRACK_TYPE_AUDIO; - } else if (trackType == TYPE_vide) { + } else if (hdlr == TYPE_vide) { return C.TRACK_TYPE_VIDEO; - } else if (trackType == TYPE_text || trackType == TYPE_sbtl || trackType == TYPE_subt - || trackType == TYPE_clcp) { + } else if (hdlr == TYPE_text || hdlr == TYPE_sbtl || hdlr == TYPE_subt || hdlr == TYPE_clcp) { return C.TRACK_TYPE_TEXT; - } else if (trackType == TYPE_meta) { + } else if (hdlr == TYPE_meta) { return C.TRACK_TYPE_METADATA; } else { return C.TRACK_TYPE_UNKNOWN; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java new file mode 100644 index 0000000000..b458a8f0f4 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.mp4; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Stores extensible metadata with handler type 'mdta'. See also the QuickTime File Format + * Specification. + */ +public final class MdtaMetadataEntry implements Metadata.Entry { + + /** The metadata key name. */ + public final String key; + /** The payload. The interpretation of the value depends on {@link #typeIndicator}. */ + public final byte[] value; + /** The four byte locale indicator. */ + public final int localeIndicator; + /** The four byte type indicator. */ + public final int typeIndicator; + + /** Creates a new metadata entry for the specified metadata key/value. */ + public MdtaMetadataEntry(String key, byte[] value, int localeIndicator, int typeIndicator) { + this.key = key; + this.value = value; + this.localeIndicator = localeIndicator; + this.typeIndicator = typeIndicator; + } + + private MdtaMetadataEntry(Parcel in) { + key = Util.castNonNull(in.readString()); + value = new byte[in.readInt()]; + in.readByteArray(value); + localeIndicator = in.readInt(); + typeIndicator = in.readInt(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + MdtaMetadataEntry other = (MdtaMetadataEntry) obj; + return key.equals(other.key) + && Arrays.equals(value, other.value) + && localeIndicator == other.localeIndicator + && typeIndicator == other.typeIndicator; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + key.hashCode(); + result = 31 * result + Arrays.hashCode(value); + result = 31 * result + localeIndicator; + result = 31 * result + typeIndicator; + return result; + } + + @Override + public String toString() { + return "mdta: key=" + key; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(key); + dest.writeInt(value.length); + dest.writeByteArray(value); + dest.writeInt(localeIndicator); + dest.writeInt(typeIndicator); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public MdtaMetadataEntry createFromParcel(Parcel in) { + return new MdtaMetadataEntry(in); + } + + @Override + public MdtaMetadataEntry[] newArray(int size) { + return new MdtaMetadataEntry[size]; + } + }; +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java index 670fe116a6..02522897ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -16,6 +16,9 @@ package com.google.android.exoplayer2.extractor.mp4; import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.GaplessInfoHolder; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.ApicFrame; import com.google.android.exoplayer2.metadata.id3.CommentFrame; @@ -25,10 +28,9 @@ import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; -/** - * Parses metadata items stored in ilst atoms. - */ +/** Utilities for handling metadata in MP4. */ /* package */ final class MetadataUtil { private static final String TAG = "MetadataUtil"; @@ -103,24 +105,73 @@ import com.google.android.exoplayer2.util.Util; private static final String LANGUAGE_UNDEFINED = "und"; + private static final int TYPE_TOP_BYTE_COPYRIGHT = 0xA9; + private static final int TYPE_TOP_BYTE_REPLACEMENT = 0xFD; // Truncated value of \uFFFD. + + private static final String MDTA_KEY_ANDROID_CAPTURE_FPS = "com.android.capture.fps"; + private static final int MDTA_TYPE_INDICATOR_FLOAT = 23; + private MetadataUtil() {} /** - * Parses a single ilst element from a {@link ParsableByteArray}. The element is read starting - * from the current position of the {@link ParsableByteArray}, and the position is advanced by the - * size of the element. The position is advanced even if the element's type is unrecognized. + * Returns a {@link Format} that is the same as the input format but includes information from the + * specified sources of metadata. + */ + public static Format getFormatWithMetadata( + int trackType, + Format format, + @Nullable Metadata udtaMetadata, + @Nullable Metadata mdtaMetadata, + GaplessInfoHolder gaplessInfoHolder) { + if (trackType == C.TRACK_TYPE_AUDIO) { + if (gaplessInfoHolder.hasGaplessInfo()) { + format = + format.copyWithGaplessInfo( + gaplessInfoHolder.encoderDelay, gaplessInfoHolder.encoderPadding); + } + // We assume all udta metadata is associated with the audio track. + if (udtaMetadata != null) { + format = format.copyWithMetadata(udtaMetadata); + } + } else if (trackType == C.TRACK_TYPE_VIDEO && mdtaMetadata != null) { + // Populate only metadata keys that are known to be specific to video. + for (int i = 0; i < mdtaMetadata.length(); i++) { + Metadata.Entry entry = mdtaMetadata.get(i); + if (entry instanceof MdtaMetadataEntry) { + MdtaMetadataEntry mdtaMetadataEntry = (MdtaMetadataEntry) entry; + if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key) + && mdtaMetadataEntry.typeIndicator == MDTA_TYPE_INDICATOR_FLOAT) { + try { + float fps = ByteBuffer.wrap(mdtaMetadataEntry.value).asFloatBuffer().get(); + format = format.copyWithFrameRate(fps); + format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring invalid framerate"); + } + } + } + } + } + return format; + } + + /** + * Parses a single userdata ilst element from a {@link ParsableByteArray}. The element is read + * starting from the current position of the {@link ParsableByteArray}, and the position is + * advanced by the size of the element. The position is advanced even if the element's type is + * unrecognized. * * @param ilst Holds the data to be parsed. * @return The parsed element, or null if the element's type was not recognized. */ - public static @Nullable Metadata.Entry parseIlstElement(ParsableByteArray ilst) { + @Nullable + public static Metadata.Entry parseIlstElement(ParsableByteArray ilst) { int position = ilst.getPosition(); int endPosition = position + ilst.readInt(); int type = ilst.readInt(); int typeTopByte = (type >> 24) & 0xFF; try { - if (typeTopByte == '\u00A9' /* Copyright char */ - || typeTopByte == '\uFFFD' /* Replacement char */) { + if (typeTopByte == TYPE_TOP_BYTE_COPYRIGHT || typeTopByte == TYPE_TOP_BYTE_REPLACEMENT) { int shortType = type & 0x00FFFFFF; if (shortType == SHORT_TYPE_COMMENT) { return parseCommentAttribute(type, ilst); @@ -185,7 +236,36 @@ import com.google.android.exoplayer2.util.Util; } } - private static @Nullable TextInformationFrame parseTextAttribute( + /** + * Parses an 'mdta' metadata entry starting at the current position in an ilst box. + * + * @param ilst The ilst box. + * @param endPosition The end position of the entry in the ilst box. + * @param key The mdta metadata entry key for the entry. + * @return The parsed element, or null if the entry wasn't recognized. + */ + @Nullable + public static MdtaMetadataEntry parseMdtaMetadataEntryFromIlst( + ParsableByteArray ilst, int endPosition, String key) { + int atomPosition; + while ((atomPosition = ilst.getPosition()) < endPosition) { + int atomSize = ilst.readInt(); + int atomType = ilst.readInt(); + if (atomType == Atom.TYPE_data) { + int typeIndicator = ilst.readInt(); + int localeIndicator = ilst.readInt(); + int dataSize = atomSize - 16; + byte[] value = new byte[dataSize]; + ilst.readBytes(value, 0, dataSize); + return new MdtaMetadataEntry(key, value, localeIndicator, typeIndicator); + } + ilst.setPosition(atomPosition + atomSize); + } + return null; + } + + @Nullable + private static TextInformationFrame parseTextAttribute( int type, String id, ParsableByteArray data) { int atomSize = data.readInt(); int atomType = data.readInt(); @@ -198,7 +278,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable CommentFrame parseCommentAttribute(int type, ParsableByteArray data) { + @Nullable + private static CommentFrame parseCommentAttribute(int type, ParsableByteArray data) { int atomSize = data.readInt(); int atomType = data.readInt(); if (atomType == Atom.TYPE_data) { @@ -210,7 +291,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable Id3Frame parseUint8Attribute( + @Nullable + private static Id3Frame parseUint8Attribute( int type, String id, ParsableByteArray data, @@ -229,7 +311,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable TextInformationFrame parseIndexAndCountAttribute( + @Nullable + private static TextInformationFrame parseIndexAndCountAttribute( int type, String attributeName, ParsableByteArray data) { int atomSize = data.readInt(); int atomType = data.readInt(); @@ -249,8 +332,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable TextInformationFrame parseStandardGenreAttribute( - ParsableByteArray data) { + @Nullable + private static TextInformationFrame parseStandardGenreAttribute(ParsableByteArray data) { int genreCode = parseUint8AttributeValue(data); String genreString = (0 < genreCode && genreCode <= STANDARD_GENRES.length) ? STANDARD_GENRES[genreCode - 1] : null; @@ -261,7 +344,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable ApicFrame parseCoverArt(ParsableByteArray data) { + @Nullable + private static ApicFrame parseCoverArt(ParsableByteArray data) { int atomSize = data.readInt(); int atomType = data.readInt(); if (atomType == Atom.TYPE_data) { @@ -285,8 +369,8 @@ import com.google.android.exoplayer2.util.Util; return null; } - private static @Nullable Id3Frame parseInternalAttribute( - ParsableByteArray data, int endPosition) { + @Nullable + private static Id3Frame parseInternalAttribute(ParsableByteArray data, int endPosition) { String domain = null; String name = null; int dataAtomPosition = -1; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index ec24bed964..5356fdb548 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -75,7 +75,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { private static final int STATE_READING_ATOM_PAYLOAD = 1; private static final int STATE_READING_SAMPLE = 2; - // Brand stored in the ftyp atom for QuickTime media. + /** Brand stored in the ftyp atom for QuickTime media. */ private static final int BRAND_QUICKTIME = Util.getIntegerCodeForString("qt "); /** @@ -377,15 +377,21 @@ public final class Mp4Extractor implements Extractor, SeekMap { long durationUs = C.TIME_UNSET; List tracks = new ArrayList<>(); - Metadata metadata = null; + // Process metadata. + Metadata udtaMetadata = null; GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder(); Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta); if (udta != null) { - metadata = AtomParsers.parseUdta(udta, isQuickTime); - if (metadata != null) { - gaplessInfoHolder.setFromMetadata(metadata); + udtaMetadata = AtomParsers.parseUdta(udta, isQuickTime); + if (udtaMetadata != null) { + gaplessInfoHolder.setFromMetadata(udtaMetadata); } } + Metadata mdtaMetadata = null; + Atom.ContainerAtom meta = moov.getContainerAtomOfType(Atom.TYPE_meta); + if (meta != null) { + mdtaMetadata = AtomParsers.parseMdtaFromMeta(meta); + } boolean ignoreEditLists = (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0; ArrayList trackSampleTables = @@ -401,15 +407,9 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Allow ten source samples per output sample, like the platform extractor. int maxInputSize = trackSampleTable.maximumSize + 3 * 10; Format format = track.format.copyWithMaxInputSize(maxInputSize); - if (track.type == C.TRACK_TYPE_AUDIO) { - if (gaplessInfoHolder.hasGaplessInfo()) { - format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding); - } - if (metadata != null) { - format = format.copyWithMetadata(metadata); - } - } + format = + MetadataUtil.getFormatWithMetadata( + track.type, format, udtaMetadata, mdtaMetadata, gaplessInfoHolder); mp4Track.trackOutput.format(format); durationUs = @@ -716,24 +716,37 @@ public final class Mp4Extractor implements Extractor, SeekMap { return false; } - /** - * Returns whether the extractor should decode a leaf atom with type {@code atom}. - */ + /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ private static boolean shouldParseLeafAtom(int atom) { - return atom == Atom.TYPE_mdhd || atom == Atom.TYPE_mvhd || atom == Atom.TYPE_hdlr - || atom == Atom.TYPE_stsd || atom == Atom.TYPE_stts || atom == Atom.TYPE_stss - || atom == Atom.TYPE_ctts || atom == Atom.TYPE_elst || atom == Atom.TYPE_stsc - || atom == Atom.TYPE_stsz || atom == Atom.TYPE_stz2 || atom == Atom.TYPE_stco - || atom == Atom.TYPE_co64 || atom == Atom.TYPE_tkhd || atom == Atom.TYPE_ftyp - || atom == Atom.TYPE_udta; + return atom == Atom.TYPE_mdhd + || atom == Atom.TYPE_mvhd + || atom == Atom.TYPE_hdlr + || atom == Atom.TYPE_stsd + || atom == Atom.TYPE_stts + || atom == Atom.TYPE_stss + || atom == Atom.TYPE_ctts + || atom == Atom.TYPE_elst + || atom == Atom.TYPE_stsc + || atom == Atom.TYPE_stsz + || atom == Atom.TYPE_stz2 + || atom == Atom.TYPE_stco + || atom == Atom.TYPE_co64 + || atom == Atom.TYPE_tkhd + || atom == Atom.TYPE_ftyp + || atom == Atom.TYPE_udta + || atom == Atom.TYPE_keys + || atom == Atom.TYPE_ilst; } - /** - * Returns whether the extractor should decode a container atom with type {@code atom}. - */ + /** Returns whether the extractor should decode a container atom with type {@code atom}. */ private static boolean shouldParseContainerAtom(int atom) { - return atom == Atom.TYPE_moov || atom == Atom.TYPE_trak || atom == Atom.TYPE_mdia - || atom == Atom.TYPE_minf || atom == Atom.TYPE_stbl || atom == Atom.TYPE_edts; + return atom == Atom.TYPE_moov + || atom == Atom.TYPE_trak + || atom == Atom.TYPE_mdia + || atom == Atom.TYPE_minf + || atom == Atom.TYPE_stbl + || atom == Atom.TYPE_edts + || atom == Atom.TYPE_meta; } private static final class Mp4Track { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java index 021c9de654..a1c90bf1f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -27,9 +27,7 @@ import java.io.IOException; */ /* package */ final class Sniffer { - /** - * The maximum number of bytes to peek when sniffing. - */ + /** The maximum number of bytes to peek when sniffing. */ private static final int SEARCH_LENGTH = 4 * 1024; private static final int[] COMPATIBLE_BRANDS = new int[] { @@ -109,15 +107,19 @@ import java.io.IOException; headerSize = Atom.LONG_HEADER_SIZE; input.peekFully(buffer.data, Atom.HEADER_SIZE, Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE); buffer.setLimit(Atom.LONG_HEADER_SIZE); - atomSize = buffer.readUnsignedLongToLong(); + atomSize = buffer.readLong(); } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { // The atom extends to the end of the file. - long endPosition = input.getLength(); - if (endPosition != C.LENGTH_UNSET) { - atomSize = endPosition - input.getPosition() + headerSize; + long fileEndPosition = input.getLength(); + if (fileEndPosition != C.LENGTH_UNSET) { + atomSize = fileEndPosition - input.getPeekPosition() + headerSize; } } + if (inputLength != C.LENGTH_UNSET && bytesSearched + atomSize > inputLength) { + // The file is invalid because the atom extends past the end of the file. + return false; + } if (atomSize < headerSize) { // The file is invalid because the atom size is too small for its header. return false; @@ -125,6 +127,13 @@ import java.io.IOException; bytesSearched += headerSize; if (atomType == Atom.TYPE_moov) { + // We have seen the moov atom. We increase the search size to make sure we don't miss an + // mvex atom because the moov's size exceeds the search length. + bytesToSearch += (int) atomSize; + if (inputLength != C.LENGTH_UNSET && bytesToSearch > inputLength) { + // Make sure we don't exceed the file size. + bytesToSearch = (int) inputLength; + } // Check for an mvex atom inside the moov atom to identify whether the file is fragmented. continue; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java index 56851fc1e0..59ea386335 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java @@ -64,6 +64,9 @@ import com.google.android.exoplayer2.util.Util; this.flags = flags; this.durationUs = durationUs; sampleCount = offsets.length; + if (flags.length > 0) { + flags[flags.length - 1] |= C.BUFFER_FLAG_LAST_SAMPLE; + } } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java index 93ce15a7ab..3741d52294 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.audio.Ac3Util; import com.google.android.exoplayer2.extractor.Extractor; @@ -140,7 +142,7 @@ public final class Ac3Extractor implements Extractor { if (!startedPacket) { // Pass data to the reader as though it's contained within a single infinitely long packet. - reader.packetStarted(firstSampleTimestampUs, true); + reader.packetStarted(firstSampleTimestampUs, FLAG_DATA_ALIGNMENT_INDICATOR); startedPacket = true; } // TODO: Make it possible for the reader to consume the dataSource directly, so that it becomes diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java index 2ef9704a7a..93724be92d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java @@ -100,7 +100,7 @@ public final class Ac3Reader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { timeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java index 04a6b571bd..77b79fa19f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; + import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -202,7 +204,7 @@ public final class AdtsExtractor implements Extractor { if (!startedPacket) { // Pass data to the reader as though it's contained within a single infinitely long packet. - reader.packetStarted(firstSampleTimestampUs, true); + reader.packetStarted(firstSampleTimestampUs, FLAG_DATA_ALIGNMENT_INDICATOR); startedPacket = true; } // TODO: Make it possible for reader to consume the dataSource directly, so that it becomes diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java index e31f67c77c..589b543170 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java @@ -141,7 +141,7 @@ public final class AdtsReader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { timeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java index a5506e2cfb..88805d9362 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java @@ -50,7 +50,8 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact FLAG_IGNORE_H264_STREAM, FLAG_DETECT_ACCESS_UNITS, FLAG_IGNORE_SPLICE_INFO_STREAM, - FLAG_OVERRIDE_CAPTION_DESCRIPTORS + FLAG_OVERRIDE_CAPTION_DESCRIPTORS, + FLAG_IGNORE_HDMV_DTS_STREAM }) public @interface Flags {} @@ -86,6 +87,12 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact * closedCaptionFormats} will be ignored if the PMT contains closed captions service descriptors. */ public static final int FLAG_OVERRIDE_CAPTION_DESCRIPTORS = 1 << 5; + /** + * Prevents the creation of {@link DtsReader} instances when receiving {@link + * TsExtractor#TS_STREAM_TYPE_HDMV_DTS} as stream type. Enabling this flag prevents a stream type + * collision between HDMV DTS audio and SCTE-35 subtitles. + */ + public static final int FLAG_IGNORE_HDMV_DTS_STREAM = 1 << 6; private static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86; @@ -142,8 +149,12 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact case TsExtractor.TS_STREAM_TYPE_AC3: case TsExtractor.TS_STREAM_TYPE_E_AC3: return new PesReader(new Ac3Reader(esInfo.language)); - case TsExtractor.TS_STREAM_TYPE_DTS: case TsExtractor.TS_STREAM_TYPE_HDMV_DTS: + if (isSet(FLAG_IGNORE_HDMV_DTS_STREAM)) { + return null; + } + // Fall through. + case TsExtractor.TS_STREAM_TYPE_DTS: return new PesReader(new DtsReader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_H262: return new PesReader(new H262Reader(buildUserDataReader(esInfo))); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java index 2e45853951..1f9b0e79d4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java @@ -80,7 +80,7 @@ public final class DtsReader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { timeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java index 0944d1810e..3f0a772b1c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -73,8 +75,8 @@ public final class DvbSubtitleReader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { - if (!dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + if ((flags & FLAG_DATA_ALIGNMENT_INDICATOR) == 0) { return; } writingSample = true; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java index fa7f78c8c0..e022fc237b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java @@ -43,9 +43,9 @@ public interface ElementaryStreamReader { * Called when a packet starts. * * @param pesTimeUs The timestamp associated with the packet. - * @param dataAlignmentIndicator The data alignment indicator associated with the packet. + * @param flags See {@link TsPayloadReader.Flags}. */ - void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator); + void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags); /** * Consumes (possibly partial) data from the current packet. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java index e9827893ee..1564157d44 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java @@ -107,7 +107,8 @@ public final class H262Reader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + // TODO (Internal b/32267012): Consider using random access indicator. this.pesTimeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java index 45e094f69d..d249c1b9da 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR; + import android.util.SparseArray; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -56,9 +58,12 @@ public final class H264Reader implements ElementaryStreamReader { // State that should not be reset on seek. private boolean hasOutputFormat; - // Per packet state that gets reset at the start of each packet. + // Per PES packet state that gets reset at the start of each PES packet. private long pesTimeUs; + // State inherited from the TS packet header. + private boolean randomAccessIndicator; + // Scratch variables to avoid allocations. private final ParsableByteArray seiWrapper; @@ -88,6 +93,7 @@ public final class H264Reader implements ElementaryStreamReader { sei.reset(); sampleReader.reset(); totalBytesWritten = 0; + randomAccessIndicator = false; } @Override @@ -100,8 +106,9 @@ public final class H264Reader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { this.pesTimeUs = pesTimeUs; + randomAccessIndicator |= (flags & FLAG_RANDOM_ACCESS_INDICATOR) != 0; } @Override @@ -220,12 +227,17 @@ public final class H264Reader implements ElementaryStreamReader { seiWrapper.setPosition(4); // NAL prefix and nal_unit() header. seiReader.consume(pesTimeUs, seiWrapper); } - sampleReader.endNalUnit(position, offset); + boolean sampleIsKeyFrame = + sampleReader.endNalUnit(position, offset, hasOutputFormat, randomAccessIndicator); + if (sampleIsKeyFrame) { + // This is either an IDR frame or the first I-frame since the random access indicator, so mark + // it as a keyframe. Clear the flag so that subsequent non-IDR I-frames are not marked as + // keyframes until we see another random access indicator. + randomAccessIndicator = false; + } } - /** - * Consumes a stream of NAL units and outputs samples. - */ + /** Consumes a stream of NAL units and outputs samples. */ private static final class SampleReader { private static final int DEFAULT_BUFFER_SIZE = 128; @@ -430,11 +442,12 @@ public final class H264Reader implements ElementaryStreamReader { isFilling = false; } - public void endNalUnit(long position, int offset) { + public boolean endNalUnit( + long position, int offset, boolean hasOutputFormat, boolean randomAccessIndicator) { if (nalUnitType == NAL_UNIT_TYPE_AUD || (detectAccessUnits && sliceHeader.isFirstVclNalUnitOfPicture(previousSliceHeader))) { // If the NAL unit ending is the start of a new sample, output the previous one. - if (readingSample) { + if (hasOutputFormat && readingSample) { int nalUnitLength = (int) (position - nalUnitStartPosition); outputSample(offset + nalUnitLength); } @@ -443,8 +456,12 @@ public final class H264Reader implements ElementaryStreamReader { sampleIsKeyframe = false; readingSample = true; } - sampleIsKeyframe |= nalUnitType == NAL_UNIT_TYPE_IDR || (allowNonIdrKeyframes - && nalUnitType == NAL_UNIT_TYPE_NON_IDR && sliceHeader.isISlice()); + boolean treatIFrameAsKeyframe = + allowNonIdrKeyframes ? sliceHeader.isISlice() : randomAccessIndicator; + sampleIsKeyframe |= + nalUnitType == NAL_UNIT_TYPE_IDR + || (treatIFrameAsKeyframe && nalUnitType == NAL_UNIT_TYPE_NON_IDR); + return sampleIsKeyframe; } private void outputSample(int offset) { @@ -486,10 +503,21 @@ public final class H264Reader implements ElementaryStreamReader { hasSliceType = true; } - public void setAll(SpsData spsData, int nalRefIdc, int sliceType, int frameNum, - int picParameterSetId, boolean fieldPicFlag, boolean bottomFieldFlagPresent, - boolean bottomFieldFlag, boolean idrPicFlag, int idrPicId, int picOrderCntLsb, - int deltaPicOrderCntBottom, int deltaPicOrderCnt0, int deltaPicOrderCnt1) { + public void setAll( + SpsData spsData, + int nalRefIdc, + int sliceType, + int frameNum, + int picParameterSetId, + boolean fieldPicFlag, + boolean bottomFieldFlagPresent, + boolean bottomFieldFlag, + boolean idrPicFlag, + int idrPicId, + int picOrderCntLsb, + int deltaPicOrderCntBottom, + int deltaPicOrderCnt0, + int deltaPicOrderCnt1) { this.spsData = spsData; this.nalRefIdc = nalRefIdc; this.sliceType = sliceType; @@ -514,23 +542,26 @@ public final class H264Reader implements ElementaryStreamReader { private boolean isFirstVclNalUnitOfPicture(SliceHeaderData other) { // See ISO 14496-10 subsection 7.4.1.2.4. - return isComplete && (!other.isComplete || frameNum != other.frameNum - || picParameterSetId != other.picParameterSetId || fieldPicFlag != other.fieldPicFlag - || (bottomFieldFlagPresent && other.bottomFieldFlagPresent - && bottomFieldFlag != other.bottomFieldFlag) - || (nalRefIdc != other.nalRefIdc && (nalRefIdc == 0 || other.nalRefIdc == 0)) - || (spsData.picOrderCountType == 0 && other.spsData.picOrderCountType == 0 - && (picOrderCntLsb != other.picOrderCntLsb - || deltaPicOrderCntBottom != other.deltaPicOrderCntBottom)) - || (spsData.picOrderCountType == 1 && other.spsData.picOrderCountType == 1 - && (deltaPicOrderCnt0 != other.deltaPicOrderCnt0 - || deltaPicOrderCnt1 != other.deltaPicOrderCnt1)) - || idrPicFlag != other.idrPicFlag - || (idrPicFlag && other.idrPicFlag && idrPicId != other.idrPicId)); + return isComplete + && (!other.isComplete + || frameNum != other.frameNum + || picParameterSetId != other.picParameterSetId + || fieldPicFlag != other.fieldPicFlag + || (bottomFieldFlagPresent + && other.bottomFieldFlagPresent + && bottomFieldFlag != other.bottomFieldFlag) + || (nalRefIdc != other.nalRefIdc && (nalRefIdc == 0 || other.nalRefIdc == 0)) + || (spsData.picOrderCountType == 0 + && other.spsData.picOrderCountType == 0 + && (picOrderCntLsb != other.picOrderCntLsb + || deltaPicOrderCntBottom != other.deltaPicOrderCntBottom)) + || (spsData.picOrderCountType == 1 + && other.spsData.picOrderCountType == 1 + && (deltaPicOrderCnt0 != other.deltaPicOrderCnt0 + || deltaPicOrderCnt1 != other.deltaPicOrderCnt1)) + || idrPicFlag != other.idrPicFlag + || (idrPicFlag && other.idrPicFlag && idrPicId != other.idrPicId)); } - } - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index 13d679c47c..88bde53746 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -104,7 +104,8 @@ public final class H265Reader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + // TODO (Internal b/32267012): Consider using random access indicator. this.pesTimeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java index 0f0f2ad981..f936fb9e43 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -63,8 +65,8 @@ public final class Id3Reader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { - if (!dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + if ((flags & FLAG_DATA_ALIGNMENT_INDICATOR) == 0) { return; } writingSample = true; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java index f401a6e736..2a633c191d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java @@ -93,7 +93,7 @@ public final class LatmReader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { timeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java index effa7d7c96..393e297818 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java @@ -83,7 +83,7 @@ public final class MpegAudioReader implements ElementaryStreamReader { } @Override - public void packetStarted(long pesTimeUs, boolean dataAlignmentIndicator) { + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { timeUs = pesTimeUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java index 91cd548367..ff755f4ece 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java @@ -78,9 +78,8 @@ public final class PesReader implements TsPayloadReader { } @Override - public final void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) - throws ParserException { - if (payloadUnitStartIndicator) { + public final void consume(ParsableByteArray data, @Flags int flags) throws ParserException { + if ((flags & FLAG_PAYLOAD_UNIT_START_INDICATOR) != 0) { switch (state) { case STATE_FINDING_HEADER: case STATE_READING_HEADER: @@ -122,7 +121,8 @@ public final class PesReader implements TsPayloadReader { if (continueRead(data, pesScratch.data, readLength) && continueRead(data, null, extendedHeaderLength)) { parseHeaderExtension(); - reader.packetStarted(timeUs, dataAlignmentIndicator); + flags |= dataAlignmentIndicator ? FLAG_DATA_ALIGNMENT_INDICATOR : 0; + reader.packetStarted(timeUs, flags); setState(STATE_READING_BODY); } break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java index c7a082aeac..f453a9cc43 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -343,7 +343,7 @@ public final class PsExtractor implements Extractor { data.readBytes(pesScratch.data, 0, extendedHeaderLength); pesScratch.setPosition(0); parseHeaderExtension(); - pesPayloadReader.packetStarted(timeUs, true); + pesPayloadReader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR); pesPayloadReader.consume(data); // We always have complete PES packets with program stream. pesPayloadReader.packetFinished(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java index d217cfcb7a..101a1f74d9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java @@ -57,7 +57,8 @@ public final class SectionReader implements TsPayloadReader { } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + public void consume(ParsableByteArray data, @Flags int flags) { + boolean payloadUnitStartIndicator = (flags & FLAG_PAYLOAD_UNIT_START_INDICATOR) != 0; int payloadStartPosition = C.POSITION_UNSET; if (payloadUnitStartIndicator) { int payloadStartOffset = data.readUnsignedByte(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index f47a481d7e..d91842423d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_PAYLOAD_UNIT_START_INDICATOR; + import android.support.annotation.IntDef; import android.util.SparseArray; import android.util.SparseBooleanArray; @@ -279,6 +281,8 @@ public final class TsExtractor implements Extractor { return RESULT_CONTINUE; } + @TsPayloadReader.Flags int packetHeaderFlags = 0; + // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. int tsPacketHeader = tsPacketBuffer.readInt(); if ((tsPacketHeader & 0x800000) != 0) { // transport_error_indicator @@ -286,7 +290,7 @@ public final class TsExtractor implements Extractor { tsPacketBuffer.setPosition(endOfPacket); return RESULT_CONTINUE; } - boolean payloadUnitStartIndicator = (tsPacketHeader & 0x400000) != 0; + packetHeaderFlags |= (tsPacketHeader & 0x400000) != 0 ? FLAG_PAYLOAD_UNIT_START_INDICATOR : 0; // Ignoring transport_priority (tsPacketHeader & 0x200000) int pid = (tsPacketHeader & 0x1FFF00) >> 8; // Ignoring transport_scrambling_control (tsPacketHeader & 0xC0) @@ -317,14 +321,20 @@ public final class TsExtractor implements Extractor { // Skip the adaptation field. if (adaptationFieldExists) { int adaptationFieldLength = tsPacketBuffer.readUnsignedByte(); - tsPacketBuffer.skipBytes(adaptationFieldLength); + int adaptationFieldFlags = tsPacketBuffer.readUnsignedByte(); + + packetHeaderFlags |= + (adaptationFieldFlags & 0x40) != 0 // random_access_indicator. + ? TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR + : 0; + tsPacketBuffer.skipBytes(adaptationFieldLength - 1 /* flags */); } // Read the payload. boolean wereTracksEnded = tracksEnded; if (shouldConsumePacketPayload(pid)) { tsPacketBuffer.setLimit(endOfPacket); - payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator); + payloadReader.consume(tsPacketBuffer, packetHeaderFlags); tsPacketBuffer.setLimit(limit); } if (mode != MODE_HLS && !wereTracksEnded && tracksEnded && inputLength != C.LENGTH_UNSET) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java index 2ea25bb2e0..a034b05696 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java @@ -15,12 +15,16 @@ */ package com.google.android.exoplayer2.extractor.ts; +import android.support.annotation.IntDef; import android.util.SparseArray; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Collections; import java.util.List; @@ -174,6 +178,29 @@ public interface TsPayloadReader { } + /** + * Contextual flags indicating the presence of indicators in the TS packet or PES packet headers. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_PAYLOAD_UNIT_START_INDICATOR, + FLAG_RANDOM_ACCESS_INDICATOR, + FLAG_DATA_ALIGNMENT_INDICATOR + }) + @interface Flags {} + + /** Indicates the presence of the payload_unit_start_indicator in the TS packet header. */ + int FLAG_PAYLOAD_UNIT_START_INDICATOR = 1; + /** + * Indicates the presence of the random_access_indicator in the TS packet header adaptation field. + */ + int FLAG_RANDOM_ACCESS_INDICATOR = 1 << 1; + /** Indicates the presence of the data_alignment_indicator in the PES header. */ + int FLAG_DATA_ALIGNMENT_INDICATOR = 1 << 2; + /** * Initializes the payload reader. * @@ -187,10 +214,10 @@ public interface TsPayloadReader { /** * Notifies the reader that a seek has occurred. - *

    - * Following a call to this method, the data passed to the next invocation of - * {@link #consume(ParsableByteArray, boolean)} will not be a continuation of the data that was - * previously passed. Hence the reader should reset any internal state. + * + *

    Following a call to this method, the data passed to the next invocation of {@link #consume} + * will not be a continuation of the data that was previously passed. Hence the reader should + * reset any internal state. */ void seek(); @@ -198,9 +225,8 @@ public interface TsPayloadReader { * Consumes the payload of a TS packet. * * @param data The TS packet. The position will be set to the start of the payload. - * @param payloadUnitStartIndicator Whether payloadUnitStartIndicator was set on the TS packet. + * @param flags See {@link Flags}. * @throws ParserException If the payload could not be parsed. */ - void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) throws ParserException; - + void consume(ParsableByteArray data, @Flags int flags) throws ParserException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 32f6bd5409..107ab9efd8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -248,9 +248,15 @@ public final class MediaCodecInfo { // If we don't know any better, we assume that the profile and level are supported. return true; } + int profile = codecProfileAndLevel.first; + int level = codecProfileAndLevel.second; + if (!isVideo && profile != CodecProfileLevel.AACObjectXHE) { + // Some devices/builds underreport audio capabilities, so assume support except for xHE-AAC + // which may not be widely supported. See https://github.com/google/ExoPlayer/issues/5145. + return true; + } for (CodecProfileLevel capabilities : getProfileLevels()) { - if (capabilities.profile == codecProfileAndLevel.first - && capabilities.level >= codecProfileAndLevel.second) { + if (capabilities.profile == profile && capabilities.level >= level) { return true; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 2d936afc2a..35f5c14f3f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -20,6 +20,7 @@ import android.media.MediaCodec; import android.media.MediaCodec.CodecException; import android.media.MediaCodec.CryptoException; import android.media.MediaCrypto; +import android.media.MediaCryptoException; import android.media.MediaFormat; import android.os.Bundle; import android.os.Looper; @@ -239,14 +240,21 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({DRAIN_ACTION_NONE, DRAIN_ACTION_FLUSH, DRAIN_ACTION_REINITIALIZE}) + @IntDef({ + DRAIN_ACTION_NONE, + DRAIN_ACTION_FLUSH, + DRAIN_ACTION_UPDATE_DRM_SESSION, + DRAIN_ACTION_REINITIALIZE + }) private @interface DrainAction {} /** No special action should be taken. */ private static final int DRAIN_ACTION_NONE = 0; /** The codec should be flushed. */ private static final int DRAIN_ACTION_FLUSH = 1; - /** The codec should be re-initialized. */ - private static final int DRAIN_ACTION_REINITIALIZE = 2; + /** The codec should be flushed and updated to use the pending DRM session. */ + private static final int DRAIN_ACTION_UPDATE_DRM_SESSION = 2; + /** The codec should be reinitialized. */ + private static final int DRAIN_ACTION_REINITIALIZE = 3; @Documented @Retention(RetentionPolicy.SOURCE) @@ -287,13 +295,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private final DecoderInputBuffer flagsOnlyBuffer; private final FormatHolder formatHolder; private final TimedValueQueue formatQueue; - private final List decodeOnlyPresentationTimestamps; + private final ArrayList decodeOnlyPresentationTimestamps; private final MediaCodec.BufferInfo outputBufferInfo; @Nullable private Format inputFormat; private Format outputFormat; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession codecDrmSession; + @Nullable private DrmSession sourceDrmSession; + @Nullable private MediaCrypto mediaCrypto; + private boolean mediaCryptoRequiresSecureDecoder; private long renderTimeLimitMs; private float rendererOperatingRate; @Nullable private MediaCodec codec; @@ -457,29 +467,36 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } - drmSession = pendingDrmSession; + setCodecDrmSession(sourceDrmSession); + String mimeType = inputFormat.sampleMimeType; - MediaCrypto wrappedMediaCrypto = null; - boolean drmSessionRequiresSecureDecoder = false; - if (drmSession != null) { - FrameworkMediaCrypto mediaCrypto = drmSession.getMediaCrypto(); + if (codecDrmSession != null) { if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); - if (drmError != null) { - // Continue for now. We may be able to avoid failure if the session recovers, or if a new - // input format causes the session to be replaced before it's used. + FrameworkMediaCrypto sessionMediaCrypto = codecDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + DrmSessionException drmError = codecDrmSession.getError(); + if (drmError != null) { + // Continue for now. We may be able to avoid failure if the session recovers, or if a + // new input format causes the session to be replaced before it's used. + } else { + // The drm session isn't open yet. + return; + } } else { - // The drm session isn't open yet. - return; + try { + mediaCrypto = new MediaCrypto(sessionMediaCrypto.uuid, sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw ExoPlaybackException.createForRenderer(e, getIndex()); + } + mediaCryptoRequiresSecureDecoder = + !sessionMediaCrypto.forceAllowInsecureDecoderComponents + && mediaCrypto.requiresSecureDecoderComponent(mimeType); } - } else { - wrappedMediaCrypto = mediaCrypto.getWrappedMediaCrypto(); - drmSessionRequiresSecureDecoder = mediaCrypto.requiresSecureDecoderComponent(mimeType); } if (deviceNeedsDrmKeysToConfigureCodecWorkaround()) { - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = codecDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(codecDrmSession.getError(), getIndex()); } else if (drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS) { // Wait for keys. return; @@ -488,7 +505,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } try { - maybeInitCodecWithFallback(wrappedMediaCrypto, drmSessionRequiresSecureDecoder); + maybeInitCodecWithFallback(mediaCrypto, mediaCryptoRequiresSecureDecoder); } catch (DecoderInitializationException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } @@ -537,7 +554,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { inputStreamEnded = false; outputStreamEnded = false; - flushOrReinitCodec(); + flushOrReinitializeCodec(); formatQueue.clear(); } @@ -552,7 +569,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; - if (drmSession != null || pendingDrmSession != null) { + if (sourceDrmSession != null || codecDrmSession != null) { // TODO: Do something better with this case. onReset(); } else { @@ -565,51 +582,40 @@ public abstract class MediaCodecRenderer extends BaseRenderer { try { releaseCodec(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - } - } + setSourceDrmSession(null); } } protected void releaseCodec() { availableCodecInfos = null; - if (codec != null) { - codecInfo = null; - codecFormat = null; - resetInputBuffer(); - resetOutputBuffer(); - resetCodecBuffers(); - waitingForKeys = false; - codecHotswapDeadlineMs = C.TIME_UNSET; - decodeOnlyPresentationTimestamps.clear(); - decoderCounters.decoderReleaseCount++; - try { - codec.stop(); - } finally { + codecInfo = null; + codecFormat = null; + resetInputBuffer(); + resetOutputBuffer(); + resetCodecBuffers(); + waitingForKeys = false; + codecHotswapDeadlineMs = C.TIME_UNSET; + decodeOnlyPresentationTimestamps.clear(); + try { + if (codec != null) { + decoderCounters.decoderReleaseCount++; try { - codec.release(); + codec.stop(); } finally { - codec = null; - if (drmSession != null && pendingDrmSession != drmSession) { - try { - drmSessionManager.releaseSession(drmSession); - } finally { - drmSession = null; - } - } + codec.release(); } } + } finally { + codec = null; + try { + if (mediaCrypto != null) { + mediaCrypto.release(); + } + } finally { + mediaCrypto = null; + mediaCryptoRequiresSecureDecoder = false; + setCodecDrmSession(null); + } } } @@ -680,12 +686,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

    The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link * #maybeInitCodec()} if the codec needs to be re-instantiated. * + * @return Whether the codec was released and reinitialized, rather than being flushed. * @throws ExoPlaybackException If an error occurs re-instantiating the codec. */ - protected final void flushOrReinitCodec() throws ExoPlaybackException { - if (flushOrReleaseCodec()) { + protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { + boolean released = flushOrReleaseCodec(); + if (released) { maybeInitCodec(); } + return released; } /** @@ -729,18 +738,18 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } private void maybeInitCodecWithFallback( - MediaCrypto crypto, boolean drmSessionRequiresSecureDecoder) + MediaCrypto crypto, boolean mediaCryptoRequiresSecureDecoder) throws DecoderInitializationException { if (availableCodecInfos == null) { try { availableCodecInfos = - new ArrayDeque<>(getAvailableCodecInfos(drmSessionRequiresSecureDecoder)); + new ArrayDeque<>(getAvailableCodecInfos(mediaCryptoRequiresSecureDecoder)); preferredDecoderInitializationException = null; } catch (DecoderQueryException e) { throw new DecoderInitializationException( inputFormat, e, - drmSessionRequiresSecureDecoder, + mediaCryptoRequiresSecureDecoder, DecoderInitializationException.DECODER_QUERY_ERROR); } } @@ -749,7 +758,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { throw new DecoderInitializationException( inputFormat, /* cause= */ null, - drmSessionRequiresSecureDecoder, + mediaCryptoRequiresSecureDecoder, DecoderInitializationException.NO_SUITABLE_DECODER_ERROR); } @@ -768,7 +777,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { availableCodecInfos.removeFirst(); DecoderInitializationException exception = new DecoderInitializationException( - inputFormat, e, drmSessionRequiresSecureDecoder, codecInfo.name); + inputFormat, e, mediaCryptoRequiresSecureDecoder, codecInfo.name); if (preferredDecoderInitializationException == null) { preferredDecoderInitializationException = exception; } else { @@ -784,11 +793,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { availableCodecInfos = null; } - private List getAvailableCodecInfos(boolean drmSessionRequiresSecureDecoder) + private List getAvailableCodecInfos(boolean mediaCryptoRequiresSecureDecoder) throws DecoderQueryException { List codecInfos = - getDecoderInfos(mediaCodecSelector, inputFormat, drmSessionRequiresSecureDecoder); - if (codecInfos.isEmpty() && drmSessionRequiresSecureDecoder) { + getDecoderInfos(mediaCodecSelector, inputFormat, mediaCryptoRequiresSecureDecoder); + if (codecInfos.isEmpty() && mediaCryptoRequiresSecureDecoder) { // The drm session indicates that a secure decoder is required, but the device does not // have one. Assuming that supportsFormat indicated support for the media being played, we // know that it does not require a secure output path. Most CDM implementations allow @@ -928,6 +937,24 @@ public abstract class MediaCodecRenderer extends BaseRenderer { outputBuffer = null; } + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setCodecDrmSession(@Nullable DrmSession session) { + DrmSession previous = codecDrmSession; + codecDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != sourceDrmSession && session != codecDrmSession) { + drmSessionManager.releaseSession(session); + } + } + /** * @return Whether it may be possible to feed more input data. * @throws ExoPlaybackException If an error occurs feeding the input buffer. @@ -1082,12 +1109,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (codecDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = codecDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(codecDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } @@ -1126,13 +1153,16 @@ public abstract class MediaCodecRenderer extends BaseRenderer { throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = + DrmSession session = drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + if (session == sourceDrmSession || session == codecDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } @@ -1143,40 +1173,58 @@ public abstract class MediaCodecRenderer extends BaseRenderer { // We have an existing codec that we may need to reconfigure or re-initialize. If the existing // codec instance is being kept then its operating rate may need to be updated. - if (pendingDrmSession != drmSession) { + + if ((sourceDrmSession == null && codecDrmSession != null) + || (sourceDrmSession != null && codecDrmSession == null) + || (sourceDrmSession != null && !codecInfo.secure) + || (Util.SDK_INT < 23 && sourceDrmSession != codecDrmSession)) { + // We might need to switch between the clear and protected output paths, or we're using DRM + // prior to API level 23 where the codec needs to be re-initialized to switch to the new DRM + // session. drainAndReinitializeCodec(); - } else { - switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { - case KEEP_CODEC_RESULT_NO: - drainAndReinitializeCodec(); - break; - case KEEP_CODEC_RESULT_YES_WITH_FLUSH: + return; + } + + switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { + case KEEP_CODEC_RESULT_NO: + drainAndReinitializeCodec(); + break; + case KEEP_CODEC_RESULT_YES_WITH_FLUSH: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } else { drainAndFlushCodec(); + } + break; + case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: + if (codecNeedsReconfigureWorkaround) { + drainAndReinitializeCodec(); + } else { + codecReconfigured = true; + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + codecNeedsAdaptationWorkaroundBuffer = + codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS + || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION + && newFormat.width == codecFormat.width + && newFormat.height == codecFormat.height); codecFormat = newFormat; updateCodecOperatingRate(); - break; - case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: - if (codecNeedsReconfigureWorkaround) { - drainAndReinitializeCodec(); - } else { - codecReconfigured = true; - codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; - codecNeedsAdaptationWorkaroundBuffer = - codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS - || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION - && newFormat.width == codecFormat.width - && newFormat.height == codecFormat.height); - codecFormat = newFormat; - updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); } - break; - case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: - codecFormat = newFormat; - updateCodecOperatingRate(); - break; - default: - throw new IllegalStateException(); // Never happens. - } + } + break; + case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } + break; + default: + throw new IllegalStateException(); // Never happens. } } @@ -1311,6 +1359,27 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + /** + * Starts draining the codec to update its DRM session. The update may occur immediately if no + * buffers have been queued to the codec. + * + * @throws ExoPlaybackException If an error occurs updating the codec's DRM session. + */ + private void drainAndUpdateCodecDrmSession() throws ExoPlaybackException { + if (Util.SDK_INT < 23) { + // The codec needs to be re-initialized to switch to the source DRM session. + drainAndReinitializeCodec(); + return; + } + if (codecReceivedBuffers) { + codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; + codecDrainAction = DRAIN_ACTION_UPDATE_DRM_SESSION; + } else { + // Nothing has been queued to the decoder, so we can do the update immediately. + updateDrmSessionOrReinitializeCodecV23(); + } + } + /** * Starts draining the codec for re-initialization. Re-initialization may occur immediately if no * buffers have been queued to the codec. @@ -1323,8 +1392,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecDrainAction = DRAIN_ACTION_REINITIALIZE; } else { // Nothing has been queued to the decoder, so we can re-initialize immediately. - releaseCodec(); - maybeInitCodec(); + reinitializeCodec(); } } @@ -1528,11 +1596,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private void processEndOfStream() throws ExoPlaybackException { switch (codecDrainAction) { case DRAIN_ACTION_REINITIALIZE: - releaseCodec(); - maybeInitCodec(); + reinitializeCodec(); + break; + case DRAIN_ACTION_UPDATE_DRM_SESSION: + updateDrmSessionOrReinitializeCodecV23(); break; case DRAIN_ACTION_FLUSH: - flushOrReinitCodec(); + flushOrReinitializeCodec(); break; case DRAIN_ACTION_NONE: default: @@ -1542,6 +1612,41 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + private void reinitializeCodec() throws ExoPlaybackException { + releaseCodec(); + maybeInitCodec(); + } + + @TargetApi(23) + private void updateDrmSessionOrReinitializeCodecV23() throws ExoPlaybackException { + FrameworkMediaCrypto sessionMediaCrypto = sourceDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + // We'd only expect this to happen if the CDM from which the pending session is obtained needs + // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme + // to another, where the new CDM hasn't been used before and needs provisioning). It would be + // possible to handle this case more efficiently (i.e. with a new renderer state that waits + // for provisioning to finish and then calls mediaCrypto.setMediaDrmSession), but the extra + // complexity is not warranted given how unlikely the case is to occur. + reinitializeCodec(); + return; + } + + if (flushOrReinitializeCodec()) { + // The codec was reinitialized. The new codec will be using the new DRM session, so there's + // nothing more to do. + return; + } + + try { + mediaCrypto.setMediaDrmSession(sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw ExoPlaybackException.createForRenderer(e, getIndex()); + } + setCodecDrmSession(sourceDrmSession); + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + } + private boolean shouldSkipOutputBuffer(long presentationTimeUs) { // We avoid using decodeOnlyPresentationTimestamps.remove(presentationTimeUs) because it would // box presentationTimeUs, creating a Long object that would need to be garbage collected. @@ -1693,7 +1798,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { */ private static boolean codecNeedsEosFlushWorkaround(String name) { return (Util.SDK_INT <= 23 && "OMX.google.vorbis.decoder".equals(name)) - || (Util.SDK_INT <= 19 && "hb2000".equals(Util.DEVICE) + || (Util.SDK_INT <= 19 + && ("hb2000".equals(Util.DEVICE) || "stvm8".equals(Util.DEVICE)) && ("OMX.amlogic.avc.decoder.awesome".equals(name) || "OMX.amlogic.avc.decoder.awesome.secure".equals(name))); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index 893601a859..9ae50179c3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -318,7 +318,23 @@ public final class MediaCodecUtil { } // Work around https://github.com/google/ExoPlayer/issues/4519. - if ("OMX.SEC.mp3.dec".equals(name) && "SM-T530".equals(Util.MODEL)) { + if ("OMX.SEC.mp3.dec".equals(name) + && (Util.MODEL.startsWith("GT-I9152") + || Util.MODEL.startsWith("GT-I9515") + || Util.MODEL.startsWith("GT-P5220") + || Util.MODEL.startsWith("GT-S7580") + || Util.MODEL.startsWith("SM-G350") + || Util.MODEL.startsWith("SM-G386") + || Util.MODEL.startsWith("SM-T231") + || Util.MODEL.startsWith("SM-T530") + || Util.MODEL.startsWith("SCH-I535") + || Util.MODEL.startsWith("SPH-L710"))) { + return false; + } + if ("OMX.brcm.audio.mp3.decoder".equals(name) + && (Util.MODEL.startsWith("GT-I9152") + || Util.MODEL.startsWith("GT-S7580") + || Util.MODEL.startsWith("SM-G350"))) { return false; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java index a2ad7fe2ce..fbed096aab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java @@ -18,8 +18,10 @@ package com.google.android.exoplayer2.metadata; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.Nullable; +import com.google.android.exoplayer2.util.Util; import java.util.Arrays; import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * A collection of metadata entries. @@ -76,6 +78,18 @@ public final class Metadata implements Parcelable { return entries[index]; } + /** + * Returns a copy of this metadata with the specified entries appended. + * + * @param entriesToAppend The entries to append. + * @return The metadata instance with the appended entries. + */ + public Metadata copyWithAppendedEntries(Entry... entriesToAppend) { + @NullableType Entry[] merged = Arrays.copyOf(entries, entries.length + entriesToAppend.length); + System.arraycopy(entriesToAppend, 0, merged, entries.length, entriesToAppend.length); + return new Metadata(Util.castNonNullTypeArray(merged)); + } + @Override public boolean equals(@Nullable Object obj) { if (this == obj) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java index 028a8eb893..ae4b7db5c9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java @@ -17,6 +17,7 @@ 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.icy.IcyDecoder; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.scte35.SpliceInfoDecoder; import com.google.android.exoplayer2.util.MimeTypes; @@ -46,38 +47,43 @@ public interface MetadataDecoderFactory { /** * Default {@link MetadataDecoder} implementation. - *

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

    The formats supported by this factory are: + * *

      - *
    • ID3 ({@link Id3Decoder})
    • - *
    • EMSG ({@link EventMessageDecoder})
    • - *
    • SCTE-35 ({@link SpliceInfoDecoder})
    • + *
    • ID3 ({@link Id3Decoder}) + *
    • EMSG ({@link EventMessageDecoder}) + *
    • SCTE-35 ({@link SpliceInfoDecoder}) + *
    • ICY ({@link IcyDecoder}) *
    */ - MetadataDecoderFactory DEFAULT = new MetadataDecoderFactory() { + MetadataDecoderFactory DEFAULT = + new MetadataDecoderFactory() { - @Override - public boolean supportsFormat(Format format) { - String mimeType = format.sampleMimeType; - return MimeTypes.APPLICATION_ID3.equals(mimeType) - || MimeTypes.APPLICATION_EMSG.equals(mimeType) - || MimeTypes.APPLICATION_SCTE35.equals(mimeType); - } - - @Override - public MetadataDecoder createDecoder(Format format) { - switch (format.sampleMimeType) { - case MimeTypes.APPLICATION_ID3: - return new Id3Decoder(); - case MimeTypes.APPLICATION_EMSG: - return new EventMessageDecoder(); - case MimeTypes.APPLICATION_SCTE35: - return new SpliceInfoDecoder(); - default: - throw new IllegalArgumentException("Attempted to create decoder for unsupported format"); - } - } - - }; + @Override + public boolean supportsFormat(Format format) { + String mimeType = format.sampleMimeType; + return MimeTypes.APPLICATION_ID3.equals(mimeType) + || MimeTypes.APPLICATION_EMSG.equals(mimeType) + || MimeTypes.APPLICATION_SCTE35.equals(mimeType) + || MimeTypes.APPLICATION_ICY.equals(mimeType); + } + @Override + public MetadataDecoder createDecoder(Format format) { + switch (format.sampleMimeType) { + case MimeTypes.APPLICATION_ID3: + return new Id3Decoder(); + case MimeTypes.APPLICATION_EMSG: + return new EventMessageDecoder(); + case MimeTypes.APPLICATION_SCTE35: + return new SpliceInfoDecoder(); + case MimeTypes.APPLICATION_ICY: + return new IcyDecoder(); + default: + throw new IllegalArgumentException( + "Attempted to create decoder for unsupported format"); + } + } + }; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java new file mode 100644 index 0000000000..1eac663956 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2018 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.icy; + +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +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.Log; +import com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Decodes ICY stream information. */ +public final class IcyDecoder implements MetadataDecoder { + + private static final String TAG = "IcyDecoder"; + + private static final Pattern METADATA_ELEMENT = Pattern.compile("(.+?)='(.+?)';"); + private static final String STREAM_KEY_NAME = "streamtitle"; + private static final String STREAM_KEY_URL = "streamurl"; + + @Override + @Nullable + @SuppressWarnings("ByteBufferBackingArray") + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = inputBuffer.data; + byte[] data = buffer.array(); + int length = buffer.limit(); + return decode(Util.fromUtf8Bytes(data, 0, length)); + } + + @Nullable + @VisibleForTesting + /* package */ Metadata decode(String metadata) { + String name = null; + String url = null; + int index = 0; + Matcher matcher = METADATA_ELEMENT.matcher(metadata); + while (matcher.find(index)) { + String key = Util.toLowerInvariant(matcher.group(1)); + String value = matcher.group(2); + switch (key) { + case STREAM_KEY_NAME: + name = value; + break; + case STREAM_KEY_URL: + url = value; + break; + default: + Log.w(TAG, "Unrecognized ICY tag: " + name); + break; + } + index = matcher.end(); + } + return (name != null || url != null) ? new Metadata(new IcyInfo(name, url)) : null; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java new file mode 100644 index 0000000000..cd8c5b17d2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2018 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.icy; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.util.List; +import java.util.Map; + +/** ICY headers. */ +public final class IcyHeaders implements Metadata.Entry { + + public static final String REQUEST_HEADER_ENABLE_METADATA_NAME = "Icy-MetaData"; + public static final String REQUEST_HEADER_ENABLE_METADATA_VALUE = "1"; + + private static final String TAG = "IcyHeaders"; + + private static final String RESPONSE_HEADER_BITRATE = "icy-br"; + private static final String RESPONSE_HEADER_GENRE = "icy-genre"; + private static final String RESPONSE_HEADER_NAME = "icy-name"; + private static final String RESPONSE_HEADER_URL = "icy-url"; + private static final String RESPONSE_HEADER_PUB = "icy-pub"; + private static final String RESPONSE_HEADER_METADATA_INTERVAL = "icy-metaint"; + + /** + * Parses {@link IcyHeaders} from response headers. + * + * @param responseHeaders The response headers. + * @return The parsed {@link IcyHeaders}, or {@code null} if no ICY headers were present. + */ + @Nullable + public static IcyHeaders parse(Map> responseHeaders) { + boolean icyHeadersPresent = false; + int bitrate = Format.NO_VALUE; + String genre = null; + String name = null; + String url = null; + boolean isPublic = false; + int metadataInterval = C.LENGTH_UNSET; + + List headers = responseHeaders.get(RESPONSE_HEADER_BITRATE); + if (headers != null) { + String bitrateHeader = headers.get(0); + try { + bitrate = Integer.parseInt(bitrateHeader) * 1000; + if (bitrate > 0) { + icyHeadersPresent = true; + } else { + Log.w(TAG, "Invalid bitrate: " + bitrateHeader); + bitrate = Format.NO_VALUE; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid bitrate header: " + bitrateHeader); + } + } + headers = responseHeaders.get(RESPONSE_HEADER_GENRE); + if (headers != null) { + genre = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_NAME); + if (headers != null) { + name = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_URL); + if (headers != null) { + url = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_PUB); + if (headers != null) { + isPublic = headers.get(0).equals("1"); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_METADATA_INTERVAL); + if (headers != null) { + String metadataIntervalHeader = headers.get(0); + try { + metadataInterval = Integer.parseInt(metadataIntervalHeader); + if (metadataInterval > 0) { + icyHeadersPresent = true; + } else { + Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader); + metadataInterval = C.LENGTH_UNSET; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader); + } + } + return icyHeadersPresent + ? new IcyHeaders(bitrate, genre, name, url, isPublic, metadataInterval) + : null; + } + + /** + * Bitrate in bits per second ({@code (icy-br * 1000)}), or {@link Format#NO_VALUE} if the header + * was not present. + */ + public final int bitrate; + /** The genre ({@code icy-genre}). */ + @Nullable public final String genre; + /** The stream name ({@code icy-name}). */ + @Nullable public final String name; + /** The URL of the radio station ({@code icy-url}). */ + @Nullable public final String url; + /** + * Whether the radio station is listed ({@code icy-pub}), or {@code false} if the header was not + * present. + */ + public final boolean isPublic; + + /** + * The interval in bytes between metadata chunks ({@code icy-metaint}), or {@link C#LENGTH_UNSET} + * if the header was not present. + */ + public final int metadataInterval; + + /** + * @param bitrate See {@link #bitrate}. + * @param genre See {@link #genre}. + * @param name See {@link #name See}. + * @param url See {@link #url}. + * @param isPublic See {@link #isPublic}. + * @param metadataInterval See {@link #metadataInterval}. + */ + public IcyHeaders( + int bitrate, + @Nullable String genre, + @Nullable String name, + @Nullable String url, + boolean isPublic, + int metadataInterval) { + Assertions.checkArgument(metadataInterval == C.LENGTH_UNSET || metadataInterval > 0); + this.bitrate = bitrate; + this.genre = genre; + this.name = name; + this.url = url; + this.isPublic = isPublic; + this.metadataInterval = metadataInterval; + } + + /* package */ IcyHeaders(Parcel in) { + bitrate = in.readInt(); + genre = in.readString(); + name = in.readString(); + url = in.readString(); + isPublic = Util.readBoolean(in); + metadataInterval = in.readInt(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + IcyHeaders other = (IcyHeaders) obj; + return bitrate == other.bitrate + && Util.areEqual(genre, other.genre) + && Util.areEqual(name, other.name) + && Util.areEqual(url, other.url) + && isPublic == other.isPublic + && metadataInterval == other.metadataInterval; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + bitrate; + result = 31 * result + (genre != null ? genre.hashCode() : 0); + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); + result = 31 * result + (isPublic ? 1 : 0); + result = 31 * result + metadataInterval; + return result; + } + + @Override + public String toString() { + return "IcyHeaders: name=\"" + + name + + "\", genre=\"" + + genre + + "\", bitrate=" + + bitrate + + ", metadataInterval=" + + metadataInterval; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(bitrate); + dest.writeString(genre); + dest.writeString(name); + dest.writeString(url); + Util.writeBoolean(dest, isPublic); + dest.writeInt(metadataInterval); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public IcyHeaders createFromParcel(Parcel in) { + return new IcyHeaders(in); + } + + @Override + public IcyHeaders[] newArray(int size) { + return new IcyHeaders[size]; + } + }; +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyInfo.java new file mode 100644 index 0000000000..a9671bb68d --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyInfo.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2018 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.icy; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Util; + +/** ICY in-stream information. */ +public final class IcyInfo implements Metadata.Entry { + + /** The stream title if present, or {@code null}. */ + @Nullable public final String title; + /** The stream title if present, or {@code null}. */ + @Nullable public final String url; + + /** + * @param title See {@link #title}. + * @param url See {@link #url}. + */ + public IcyInfo(@Nullable String title, @Nullable String url) { + this.title = title; + this.url = url; + } + + /* package */ IcyInfo(Parcel in) { + title = in.readString(); + url = in.readString(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + IcyInfo other = (IcyInfo) obj; + return Util.areEqual(title, other.title) && Util.areEqual(url, other.url); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (title != null ? title.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "ICY: title=\"" + title + "\", url=\"" + url + "\""; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(title); + dest.writeString(url); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + + @Override + public IcyInfo createFromParcel(Parcel in) { + return new IcyInfo(in); + } + + @Override + public IcyInfo[] newArray(int size) { + return new IcyInfo[size]; + } + }; +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java new file mode 100644 index 0000000000..28a5abafb9 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -0,0 +1,357 @@ +/* + * Copyright (C) 2019 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.offline; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.VersionTable; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; + +/** + * A {@link DownloadIndex} which uses SQLite to persist {@link DownloadState}s. + * + *

    Database access may take a long time, do not call methods of this class from + * the application main thread. + */ +public final class DefaultDownloadIndex implements DownloadIndex { + + @VisibleForTesting + /* package */ static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "Downloads"; + + @VisibleForTesting /* package */ static final int TABLE_VERSION = 1; + + private final DatabaseProvider databaseProvider; + @Nullable private DownloadsTable downloadTable; + + /** + * Creates a DefaultDownloadIndex which stores the {@link DownloadState}s on a SQLite database + * provided by {@code databaseProvider}. + * + * @param databaseProvider A DatabaseProvider which provides the database which will be used to + * store DownloadStatus table. + */ + public DefaultDownloadIndex(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + @Override + @Nullable + public DownloadState getDownloadState(String id) { + return getDownloadTable().get(id); + } + + @Override + public DownloadStateCursor getDownloadStates(@DownloadState.State int... states) { + return getDownloadTable().get(states); + } + + @Override + public void putDownloadState(DownloadState downloadState) { + getDownloadTable().replace(downloadState); + } + + @Override + public void removeDownloadState(String id) { + getDownloadTable().delete(id); + } + + private DownloadsTable getDownloadTable() { + if (downloadTable == null) { + downloadTable = new DownloadsTable(databaseProvider); + } + return downloadTable; + } + + private static final class DownloadStateCursorImpl implements DownloadStateCursor { + + private final Cursor cursor; + + private DownloadStateCursorImpl(Cursor cursor) { + this.cursor = cursor; + } + + @Override + public DownloadState getDownloadState() { + return DownloadsTable.getDownloadState(cursor); + } + + @Override + public int getCount() { + return cursor.getCount(); + } + + @Override + public int getPosition() { + return cursor.getPosition(); + } + + @Override + public boolean moveToPosition(int position) { + return cursor.moveToPosition(position); + } + + @Override + public void close() { + cursor.close(); + } + + @Override + public boolean isClosed() { + return cursor.isClosed(); + } + } + + private static final class DownloadsTable { + + private static final String COLUMN_ID = "id"; + private static final String COLUMN_TYPE = "title"; + private static final String COLUMN_URI = "subtitle"; + private static final String COLUMN_CACHE_KEY = "cache_key"; + private static final String COLUMN_STATE = "state"; + private static final String COLUMN_DOWNLOAD_PERCENTAGE = "download_percentage"; + private static final String COLUMN_DOWNLOADED_BYTES = "downloaded_bytes"; + private static final String COLUMN_TOTAL_BYTES = "total_bytes"; + private static final String COLUMN_FAILURE_REASON = "failure_reason"; + private static final String COLUMN_STOP_FLAGS = "stop_flags"; + private static final String COLUMN_START_TIME_MS = "start_time_ms"; + private static final String COLUMN_UPDATE_TIME_MS = "update_time_ms"; + private static final String COLUMN_STREAM_KEYS = "stream_keys"; + private static final String COLUMN_CUSTOM_METADATA = "custom_metadata"; + + private static final int COLUMN_INDEX_ID = 0; + private static final int COLUMN_INDEX_TYPE = 1; + private static final int COLUMN_INDEX_URI = 2; + private static final int COLUMN_INDEX_CACHE_KEY = 3; + private static final int COLUMN_INDEX_STATE = 4; + private static final int COLUMN_INDEX_DOWNLOAD_PERCENTAGE = 5; + private static final int COLUMN_INDEX_DOWNLOADED_BYTES = 6; + private static final int COLUMN_INDEX_TOTAL_BYTES = 7; + private static final int COLUMN_INDEX_FAILURE_REASON = 8; + private static final int COLUMN_INDEX_STOP_FLAGS = 9; + private static final int COLUMN_INDEX_START_TIME_MS = 10; + private static final int COLUMN_INDEX_UPDATE_TIME_MS = 11; + private static final int COLUMN_INDEX_STREAM_KEYS = 12; + private static final int COLUMN_INDEX_CUSTOM_METADATA = 13; + + private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?"; + + private static final String[] COLUMNS = + new String[] { + COLUMN_ID, + COLUMN_TYPE, + COLUMN_URI, + COLUMN_CACHE_KEY, + COLUMN_STATE, + COLUMN_DOWNLOAD_PERCENTAGE, + COLUMN_DOWNLOADED_BYTES, + COLUMN_TOTAL_BYTES, + COLUMN_FAILURE_REASON, + COLUMN_STOP_FLAGS, + COLUMN_START_TIME_MS, + COLUMN_UPDATE_TIME_MS, + COLUMN_STREAM_KEYS, + COLUMN_CUSTOM_METADATA + }; + + private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME; + private static final String SQL_CREATE_TABLE = + "CREATE TABLE " + + TABLE_NAME + + " (" + + COLUMN_ID + + " TEXT PRIMARY KEY NOT NULL," + + COLUMN_TYPE + + " TEXT NOT NULL," + + COLUMN_URI + + " TEXT NOT NULL," + + COLUMN_CACHE_KEY + + " TEXT," + + COLUMN_STATE + + " INTEGER NOT NULL," + + COLUMN_DOWNLOAD_PERCENTAGE + + " REAL NOT NULL," + + COLUMN_DOWNLOADED_BYTES + + " INTEGER NOT NULL," + + COLUMN_TOTAL_BYTES + + " INTEGER NOT NULL," + + COLUMN_FAILURE_REASON + + " INTEGER NOT NULL," + + COLUMN_STOP_FLAGS + + " INTEGER NOT NULL," + + COLUMN_START_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_UPDATE_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_STREAM_KEYS + + " TEXT NOT NULL," + + COLUMN_CUSTOM_METADATA + + " BLOB NOT NULL)"; + + private final DatabaseProvider databaseProvider; + + public DownloadsTable(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + VersionTable versionTable = new VersionTable(databaseProvider); + int version = versionTable.getVersion(VersionTable.FEATURE_OFFLINE); + if (version == VersionTable.VERSION_UNSET || version > TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + writableDatabase.execSQL(SQL_DROP_TABLE_IF_EXISTS); + writableDatabase.execSQL(SQL_CREATE_TABLE); + versionTable.setVersion(VersionTable.FEATURE_OFFLINE, TABLE_VERSION); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } else if (version < TABLE_VERSION) { + // There is no previous version currently. + throw new IllegalStateException(); + } + } + + public void replace(DownloadState downloadState) { + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, downloadState.id); + values.put(COLUMN_TYPE, downloadState.type); + values.put(COLUMN_URI, downloadState.uri.toString()); + values.put(COLUMN_CACHE_KEY, downloadState.cacheKey); + values.put(COLUMN_STATE, downloadState.state); + values.put(COLUMN_DOWNLOAD_PERCENTAGE, downloadState.downloadPercentage); + values.put(COLUMN_DOWNLOADED_BYTES, downloadState.downloadedBytes); + values.put(COLUMN_TOTAL_BYTES, downloadState.totalBytes); + values.put(COLUMN_FAILURE_REASON, downloadState.failureReason); + values.put(COLUMN_STOP_FLAGS, downloadState.stopFlags); + values.put(COLUMN_START_TIME_MS, downloadState.startTimeMs); + values.put(COLUMN_UPDATE_TIME_MS, downloadState.updateTimeMs); + values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(downloadState.streamKeys)); + values.put(COLUMN_CUSTOM_METADATA, downloadState.customMetadata); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + + @Nullable + public DownloadState get(String id) { + String[] selectionArgs = {id}; + try (Cursor cursor = query(COLUMN_SELECTION_ID, selectionArgs)) { + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToNext(); + DownloadState downloadState = getDownloadState(cursor); + Assertions.checkState(id.equals(downloadState.id)); + return downloadState; + } + } + + public DownloadStateCursor get(@DownloadState.State int... states) { + String selection = null; + if (states.length > 0) { + StringBuilder selectionBuilder = new StringBuilder(); + selectionBuilder.append(COLUMN_STATE).append(" IN ("); + for (int i = 0; i < states.length; i++) { + if (i > 0) { + selectionBuilder.append(','); + } + selectionBuilder.append(states[i]); + } + selectionBuilder.append(')'); + selection = selectionBuilder.toString(); + } + Cursor cursor = query(selection, /* selectionArgs= */ null); + return new DownloadStateCursorImpl(cursor); + } + + public void delete(String id) { + String[] selectionArgs = {id}; + databaseProvider.getWritableDatabase().delete(TABLE_NAME, COLUMN_SELECTION_ID, selectionArgs); + } + + private Cursor query(@Nullable String selection, @Nullable String[] selectionArgs) { + String sortOrder = COLUMN_START_TIME_MS + " ASC"; + return databaseProvider + .getReadableDatabase() + .query( + TABLE_NAME, + COLUMNS, + selection, + selectionArgs, + /* groupBy= */ null, + /* having= */ null, + sortOrder); + } + + private static DownloadState getDownloadState(Cursor cursor) { + return new DownloadState( + cursor.getString(COLUMN_INDEX_ID), + cursor.getString(COLUMN_INDEX_TYPE), + Uri.parse(cursor.getString(COLUMN_INDEX_URI)), + cursor.getString(COLUMN_INDEX_CACHE_KEY), + cursor.getInt(COLUMN_INDEX_STATE), + cursor.getFloat(COLUMN_INDEX_DOWNLOAD_PERCENTAGE), + cursor.getLong(COLUMN_INDEX_DOWNLOADED_BYTES), + cursor.getLong(COLUMN_INDEX_TOTAL_BYTES), + cursor.getInt(COLUMN_INDEX_FAILURE_REASON), + cursor.getInt(COLUMN_INDEX_STOP_FLAGS), + cursor.getLong(COLUMN_INDEX_START_TIME_MS), + cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS), + decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)), + cursor.getBlob(COLUMN_INDEX_CUSTOM_METADATA)); + } + + private static String encodeStreamKeys(StreamKey[] streamKeys) { + StringBuilder stringBuilder = new StringBuilder(); + for (StreamKey streamKey : streamKeys) { + stringBuilder + .append(streamKey.periodIndex) + .append('.') + .append(streamKey.groupIndex) + .append('.') + .append(streamKey.trackIndex) + .append(','); + } + if (stringBuilder.length() > 0) { + stringBuilder.setLength(stringBuilder.length() - 1); + } + return stringBuilder.toString(); + } + + private static StreamKey[] decodeStreamKeys(String encodedStreamKeys) { + if (encodedStreamKeys.isEmpty()) { + return new StreamKey[0]; + } + String[] streamKeysStrings = Util.split(encodedStreamKeys, ","); + int streamKeysCount = streamKeysStrings.length; + StreamKey[] streamKeys = new StreamKey[streamKeysCount]; + for (int i = 0; i < streamKeysCount; i++) { + String[] indices = Util.split(streamKeysStrings[i], "\\."); + Assertions.checkState(indices.length == 3); + streamKeys[i] = + new StreamKey( + Integer.parseInt(indices[0]), + Integer.parseInt(indices[1]), + Integer.parseInt(indices[2])); + } + return streamKeys; + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java index 2c7b5069b9..40ea094b5e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java @@ -77,7 +77,7 @@ public final class DownloadAction { * * @param type The type of the action. * @param uri The URI of the media to be downloaded. - * @param keys Keys of tracks to be downloaded. If empty, all tracks will be downloaded. + * @param keys Keys of streams to be downloaded. If empty, all streams will be downloaded. * @param customCacheKey A custom key for cache indexing, or null. * @param data Optional custom data for this action. If {@code null} an empty array will be used. */ @@ -108,6 +108,8 @@ public final class DownloadAction { /* data= */ null); } + /** The unique content id. */ + public final String id; /** The type of the action. */ public final String type; /** The uri being downloaded or removed. */ @@ -115,8 +117,8 @@ public final class DownloadAction { /** Whether this is a remove action. If false, this is a download action. */ public final boolean isRemoveAction; /** - * Keys of tracks to be downloaded. If empty, all tracks will be downloaded. Empty if this action - * is a remove action. + * Keys of streams to be downloaded. If empty, all streams will be downloaded. Empty if this + * action is a remove action. */ public final List keys; /** A custom key for cache indexing, or null. */ @@ -128,8 +130,8 @@ public final class DownloadAction { * @param type The type of the action. * @param uri The uri being downloaded or removed. * @param isRemoveAction Whether this is a remove action. If false, this is a download action. - * @param keys Keys of tracks to be downloaded. If empty, all tracks will be downloaded. Empty if - * this action is a remove action. + * @param keys Keys of streams to be downloaded. If empty, all streams will be downloaded. Empty + * if this action is a remove action. * @param customCacheKey A custom key for cache indexing, or null. * @param data Custom data for this action. Null if this action is a remove action. */ @@ -140,6 +142,7 @@ public final class DownloadAction { List keys, @Nullable String customCacheKey, @Nullable byte[] data) { + this.id = customCacheKey != null ? customCacheKey : uri.toString(); this.type = type; this.uri = uri; this.isRemoveAction = isRemoveAction; @@ -153,7 +156,7 @@ public final class DownloadAction { ArrayList mutableKeys = new ArrayList<>(keys); Collections.sort(mutableKeys); this.keys = Collections.unmodifiableList(mutableKeys); - this.data = data != null ? data : Util.EMPTY_BYTE_ARRAY; + this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; } } @@ -171,12 +174,10 @@ public final class DownloadAction { /** Returns whether this is an action for the same media as the {@code other}. */ public boolean isSameMedia(DownloadAction other) { - return customCacheKey == null - ? other.customCacheKey == null && uri.equals(other.uri) - : customCacheKey.equals(other.customCacheKey); + return id.equals(other.id); } - /** Returns keys of tracks to be downloaded. */ + /** Returns keys of streams to be downloaded. */ public List getKeys() { return keys; } @@ -187,7 +188,8 @@ public final class DownloadAction { return false; } DownloadAction that = (DownloadAction) o; - return type.equals(that.type) + return id.equals(that.id) + && type.equals(that.type) && uri.equals(that.uri) && isRemoveAction == that.isRemoveAction && keys.equals(that.keys) @@ -198,6 +200,7 @@ public final class DownloadAction { @Override public final int hashCode() { int result = type.hashCode(); + result = 31 * result + id.hashCode(); result = 31 * result + uri.hashCode(); result = 31 * result + (isRemoveAction ? 1 : 0); result = 31 * result + keys.hashCode(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadActionUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadActionUtil.java new file mode 100644 index 0000000000..f722f9b59b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadActionUtil.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2018 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.offline; + +import com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; + +/** {@link DownloadAction} related utility methods. */ +public class DownloadActionUtil { + + private DownloadActionUtil() {} + + /** + * Merge {@link DownloadAction}s in {@code actionQueue} to minimum number of actions. + * + *

    All actions must have the same type and must be for the same media. + * + * @param actionQueue Queue of actions. Must not be empty. + * @return The first action in the queue. + */ + public static DownloadAction mergeActions(ArrayDeque actionQueue) { + DownloadAction removeAction = null; + DownloadAction downloadAction = null; + HashSet keys = new HashSet<>(); + boolean downloadAllTracks = false; + DownloadAction firstAction = Assertions.checkNotNull(actionQueue.peek()); + + while (!actionQueue.isEmpty()) { + DownloadAction action = actionQueue.remove(); + Assertions.checkState(action.type.equals(firstAction.type)); + Assertions.checkState(action.isSameMedia(firstAction)); + if (action.isRemoveAction) { + removeAction = action; + downloadAction = null; + keys.clear(); + downloadAllTracks = false; + } else { + if (!downloadAllTracks) { + if (action.keys.isEmpty()) { + downloadAllTracks = true; + keys.clear(); + } else { + keys.addAll(action.keys); + } + } + downloadAction = action; + } + } + + if (removeAction != null) { + actionQueue.add(removeAction); + } + if (downloadAction != null) { + actionQueue.add( + DownloadAction.createDownloadAction( + downloadAction.type, + downloadAction.uri, + new ArrayList<>(keys), + downloadAction.customCacheKey, + downloadAction.data)); + } + return Assertions.checkNotNull(actionQueue.peek()); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 044bd8cc8a..e799aff4b2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -19,18 +19,66 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.support.annotation.Nullable; +import android.util.SparseIntArray; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.BaseTrackSelection; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Parameters; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.TransferListener; 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.Collections; import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * A helper for initializing and removing downloads. * + *

    The helper extracts track information from the media, selects tracks for downloading, and + * creates {@link DownloadAction download actions} based on the selected tracks. + * + *

    A typical usage of DownloadHelper follows these steps: + * + *

      + *
    1. Construct the download helper with information about the {@link RenderersFactory renderers} + * and {@link DefaultTrackSelector.Parameters parameters} for track selection. + *
    2. Prepare the helper using {@link #prepare(Callback)} and wait for the callback. + *
    3. Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link + * #getTrackSelections(int, int)}, and make adjustments using {@link + * #clearTrackSelections(int)}, {@link #replaceTrackSelections(int, Parameters)} and {@link + * #addTrackSelection(int, Parameters)}. + *
    4. Create download actions for the selected track using {@link #getDownloadAction(byte[])}. + *
    + * * @param The manifest type. */ public abstract class DownloadHelper { + /** + * The default parameters used for track selection for downloading. This default selects the + * highest bitrate audio and video tracks which are supported by the renderers. + */ + public static final DefaultTrackSelector.Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS = + new DefaultTrackSelector.ParametersBuilder().setForceHighestSupportedBitrate(true).build(); + /** A callback to be notified when the {@link DownloadHelper} is prepared. */ public interface Callback { @@ -39,7 +87,7 @@ public abstract class DownloadHelper { * * @param helper The reporting {@link DownloadHelper}. */ - void onPrepared(DownloadHelper helper); + void onPrepared(DownloadHelper helper); /** * Called when preparation fails. @@ -47,27 +95,51 @@ public abstract class DownloadHelper { * @param helper The reporting {@link DownloadHelper}. * @param e The error. */ - void onPrepareError(DownloadHelper helper, IOException e); + void onPrepareError(DownloadHelper helper, IOException e); } private final String downloadType; private final Uri uri; @Nullable private final String cacheKey; + private final DefaultTrackSelector trackSelector; + private final RendererCapabilities[] rendererCapabilities; + private final SparseIntArray scratchSet; + private int currentTrackSelectionPeriodIndex; @Nullable private T manifest; - @Nullable private TrackGroupArray[] trackGroupArrays; + private TrackGroupArray @MonotonicNonNull [] trackGroupArrays; + private MappedTrackInfo @MonotonicNonNull [] mappedTrackInfos; + private List @MonotonicNonNull [][] trackSelectionsByPeriodAndRenderer; + private List @MonotonicNonNull [][] immutableTrackSelectionsByPeriodAndRenderer; /** - * Create download helper. + * Creates download helper. * * @param downloadType A download type. This value will be used as {@link DownloadAction#type}. * @param uri A {@link Uri}. * @param cacheKey An optional cache key. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks + * are selected. + * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by + * {@code renderersFactory}. */ - public DownloadHelper(String downloadType, Uri uri, @Nullable String cacheKey) { + public DownloadHelper( + String downloadType, + Uri uri, + @Nullable String cacheKey, + DefaultTrackSelector.Parameters trackSelectorParameters, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager) { this.downloadType = downloadType; this.uri = uri; this.cacheKey = cacheKey; + this.trackSelector = new DefaultTrackSelector(new DownloadTrackSelection.Factory()); + this.rendererCapabilities = Util.getRendererCapabilities(renderersFactory, drmSessionManager); + this.scratchSet = new SparseIntArray(); + trackSelector.setParameters(trackSelectorParameters); + trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); } /** @@ -77,21 +149,28 @@ public abstract class DownloadHelper { * will be invoked on the calling thread unless that thread does not have an associated {@link * Looper}, in which case it will be called on the application's main thread. */ - public final void prepare(final Callback callback) { - final Handler handler = + public final void prepare(Callback callback) { + Handler handler = new Handler(Looper.myLooper() != null ? Looper.myLooper() : Looper.getMainLooper()); - new Thread() { - @Override - public void run() { - try { - manifest = loadManifest(uri); - trackGroupArrays = getTrackGroupArrays(manifest); - handler.post(() -> callback.onPrepared(DownloadHelper.this)); - } catch (final IOException e) { - handler.post(() -> callback.onPrepareError(DownloadHelper.this, e)); - } - } - }.start(); + new Thread( + () -> { + try { + manifest = loadManifest(uri); + trackGroupArrays = getTrackGroupArrays(manifest); + initializeTrackSelectionLists(trackGroupArrays.length, rendererCapabilities.length); + mappedTrackInfos = new MappedTrackInfo[trackGroupArrays.length]; + for (int i = 0; i < trackGroupArrays.length; i++) { + TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); + trackSelector.onSelectionActivated(trackSelectorResult.info); + mappedTrackInfos[i] = + Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + } + handler.post(() -> callback.onPrepared(DownloadHelper.this)); + } catch (final IOException e) { + handler.post(() -> callback.onPrepareError(DownloadHelper.this, e)); + } + }) + .start(); } /** Returns the manifest. Must not be called until after preparation completes. */ @@ -113,6 +192,8 @@ public abstract class DownloadHelper { * Returns the track groups for the given period. Must not be called until after preparation * completes. * + *

    Use {@link #getMappedTrackInfo(int)} to get the track groups mapped to renderers. + * * @param periodIndex The period index. * @return The track groups for the period. May be {@link TrackGroupArray#EMPTY} for single stream * content. @@ -123,16 +204,103 @@ public abstract class DownloadHelper { } /** - * Builds a {@link DownloadAction} for downloading the specified tracks. Must not be called until + * Returns the mapped track info for the given period. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index. + * @return The {@link MappedTrackInfo} for the period. + */ + public final MappedTrackInfo getMappedTrackInfo(int periodIndex) { + Assertions.checkNotNull(mappedTrackInfos); + return mappedTrackInfos[periodIndex]; + } + + /** + * Returns all {@link TrackSelection track selections} for a period and renderer. Must not be + * called until after preparation completes. + * + * @param periodIndex The period index. + * @param rendererIndex The renderer index. + * @return A list of selected {@link TrackSelection track selections}. + */ + public final List getTrackSelections(int periodIndex, int rendererIndex) { + Assertions.checkNotNull(immutableTrackSelectionsByPeriodAndRenderer); + return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; + } + + /** + * Clears the selection of tracks for a period. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index for which track selections are cleared. + */ + public final void clearTrackSelections(int periodIndex) { + Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); + for (int i = 0; i < rendererCapabilities.length; i++) { + trackSelectionsByPeriodAndRenderer[periodIndex][i].clear(); + } + } + + /** + * Replaces a selection of tracks to be downloaded. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index for which the track selection is replaced. + * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new + * selection of tracks. + */ + public final void replaceTrackSelections( + int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { + clearTrackSelections(periodIndex); + addTrackSelection(periodIndex, trackSelectorParameters); + } + + /** + * Adds a selection of tracks to be downloaded. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index this track selection is added for. + * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new + * selection of tracks. + */ + public final void addTrackSelection( + int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { + Assertions.checkNotNull(trackGroupArrays); + Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); + trackSelector.setParameters(trackSelectorParameters); + runTrackSelection(periodIndex); + } + + /** + * Builds a {@link DownloadAction} for downloading the selected tracks. Must not be called until * after preparation completes. * * @param data Application provided data to store in {@link DownloadAction#data}. - * @param trackKeys The selected tracks. If empty, all streams will be downloaded. * @return The built {@link DownloadAction}. */ - public final DownloadAction getDownloadAction(@Nullable byte[] data, List trackKeys) { - return DownloadAction.createDownloadAction( - downloadType, uri, toStreamKeys(trackKeys), cacheKey, data); + public final DownloadAction getDownloadAction(@Nullable byte[] data) { + Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); + Assertions.checkNotNull(trackGroupArrays); + List streamKeys = new ArrayList<>(); + int periodCount = trackSelectionsByPeriodAndRenderer.length; + for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { + int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length; + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + List trackSelectionList = + trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; + for (int selectionIndex = 0; selectionIndex < trackSelectionList.size(); selectionIndex++) { + TrackSelection trackSelection = trackSelectionList.get(selectionIndex); + int trackGroupIndex = + trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); + int trackCount = trackSelection.length(); + for (int trackListIndex = 0; trackListIndex < trackCount; trackListIndex++) { + int trackIndex = trackSelection.getIndexInTrackGroup(trackListIndex); + streamKeys.add(toStreamKey(periodIndex, trackGroupIndex, trackIndex)); + } + } + } + } + return DownloadAction.createDownloadAction(downloadType, uri, streamKeys, cacheKey, data); } /** @@ -161,10 +329,151 @@ public abstract class DownloadHelper { protected abstract TrackGroupArray[] getTrackGroupArrays(T manifest); /** - * Converts a list of {@link TrackKey track keys} to {@link StreamKey stream keys}. + * Converts a track of a track group of a period to the corresponding {@link StreamKey}. * - * @param trackKeys A list of track keys. - * @return A corresponding list of stream keys. + * @param periodIndex The index of the containing period. + * @param trackGroupIndex The index of the containing track group within the period. + * @param trackIndexInTrackGroup The index of the track within the track group. + * @return The corresponding {@link StreamKey}. */ - protected abstract List toStreamKeys(List trackKeys); + protected abstract StreamKey toStreamKey( + int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup); + + @SuppressWarnings("unchecked") + @EnsuresNonNull("trackSelectionsByPeriodAndRenderer") + private void initializeTrackSelectionLists(int periodCount, int rendererCount) { + trackSelectionsByPeriodAndRenderer = + (List[][]) new List[periodCount][rendererCount]; + immutableTrackSelectionsByPeriodAndRenderer = + (List[][]) new List[periodCount][rendererCount]; + for (int i = 0; i < periodCount; i++) { + for (int j = 0; j < rendererCount; j++) { + trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>(); + immutableTrackSelectionsByPeriodAndRenderer[i][j] = + Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]); + } + } + } + + /** + * Runs the track selection for a given period index with the current parameters. The selected + * tracks will be added to {@link #trackSelectionsByPeriodAndRenderer}. + */ + // Intentional reference comparison of track group instances. + @SuppressWarnings("ReferenceEquality") + @RequiresNonNull({"trackGroupArrays", "trackSelectionsByPeriodAndRenderer"}) + private TrackSelectorResult runTrackSelection(int periodIndex) { + // TODO: Use actual timeline and media period id. + MediaPeriodId dummyMediaPeriodId = new MediaPeriodId(new Object()); + Timeline dummyTimeline = Timeline.EMPTY; + currentTrackSelectionPeriodIndex = periodIndex; + try { + TrackSelectorResult trackSelectorResult = + trackSelector.selectTracks( + rendererCapabilities, + trackGroupArrays[periodIndex], + dummyMediaPeriodId, + dummyTimeline); + for (int i = 0; i < trackSelectorResult.length; i++) { + TrackSelection newSelection = trackSelectorResult.selections.get(i); + if (newSelection == null) { + continue; + } + List existingSelectionList = + trackSelectionsByPeriodAndRenderer[currentTrackSelectionPeriodIndex][i]; + boolean mergedWithExistingSelection = false; + for (int j = 0; j < existingSelectionList.size(); j++) { + TrackSelection existingSelection = existingSelectionList.get(j); + if (existingSelection.getTrackGroup() == newSelection.getTrackGroup()) { + // Merge with existing selection. + scratchSet.clear(); + for (int k = 0; k < existingSelection.length(); k++) { + scratchSet.put(existingSelection.getIndexInTrackGroup(k), 0); + } + for (int k = 0; k < newSelection.length(); k++) { + scratchSet.put(newSelection.getIndexInTrackGroup(k), 0); + } + int[] mergedTracks = new int[scratchSet.size()]; + for (int k = 0; k < scratchSet.size(); k++) { + mergedTracks[k] = scratchSet.keyAt(k); + } + existingSelectionList.set( + j, new DownloadTrackSelection(existingSelection.getTrackGroup(), mergedTracks)); + mergedWithExistingSelection = true; + break; + } + } + if (!mergedWithExistingSelection) { + existingSelectionList.add(newSelection); + } + } + return trackSelectorResult; + } catch (ExoPlaybackException e) { + // DefaultTrackSelector does not throw exceptions during track selection. + throw new UnsupportedOperationException(e); + } + } + + private static final class DownloadTrackSelection extends BaseTrackSelection { + + private static final class Factory implements TrackSelection.Factory { + + @Override + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType TrackSelection[] selections = new TrackSelection[definitions.length]; + for (int i = 0; i < definitions.length; i++) { + selections[i] = + definitions[i] == null + ? null + : new DownloadTrackSelection(definitions[i].group, definitions[i].tracks); + } + return selections; + } + } + + public DownloadTrackSelection(TrackGroup trackGroup, int[] tracks) { + super(trackGroup, tracks); + } + + @Override + public int getSelectedIndex() { + return 0; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_UNKNOWN; + } + + @Nullable + @Override + public Object getSelectionData() { + return null; + } + } + + private static final class DummyBandwidthMeter implements BandwidthMeter { + + @Override + public long getBitrateEstimate() { + return 0; + } + + @Nullable + @Override + public TransferListener getTransferListener() { + return null; + } + + @Override + public void addEventListener(Handler eventHandler, EventListener eventListener) { + // Do nothing. + } + + @Override + public void removeEventListener(EventListener eventListener) { + // Do nothing. + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java new file mode 100644 index 0000000000..7b903d3321 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2019 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.offline; + +import android.support.annotation.Nullable; + +/** Persists {@link DownloadState}s. */ +interface DownloadIndex { + + /** + * Returns the {@link DownloadState} with the given {@code id}, or null. + * + * @param id ID of a {@link DownloadState}. + * @return The {@link DownloadState} with the given {@code id}, or null if a download state with + * this id doesn't exist. + */ + @Nullable + DownloadState getDownloadState(String id); + + /** + * Returns a {@link DownloadStateCursor} to {@link DownloadState}s with the given {@code states}. + * + * @param states Returns only the {@link DownloadState}s with this states. If empty, returns all. + * @return A cursor to {@link DownloadState}s with the given {@code states}. + */ + DownloadStateCursor getDownloadStates(@DownloadState.State int... states); + + /** + * Adds or replaces a {@link DownloadState}. + * + * @param downloadState The {@link DownloadState} to be added. + */ + void putDownloadState(DownloadState downloadState); + + /** Removes the {@link DownloadState} with the given {@code id}. */ + void removeDownloadState(String id); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java new file mode 100644 index 0000000000..63602c7641 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2019 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.offline; + +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.offline.DownloadState.State; +import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; + +/** {@link DownloadIndex} related utility methods. */ +public final class DownloadIndexUtil { + + /** An interface to provide custom download ids during ActionFile upgrade. */ + public interface DownloadIdProvider { + + /** + * Returns a custom download id for given action. + * + * @param downloadAction The action which is an id requested for. + * @return A custom download id for given action. + */ + String getId(DownloadAction downloadAction); + } + + private DownloadIndexUtil() {} + + /** + * Upgrades an {@link ActionFile} to {@link DownloadIndex}. + * + *

    This method shouldn't be called while {@link DownloadIndex} is used by {@link + * DownloadManager}. + * + * @param actionFile The action file to upgrade. + * @param downloadIndex Actions are converted to {@link DownloadState}s and stored in this index. + * @param downloadIdProvider A nullable custom download id provider. + * @throws IOException If there is an error during loading actions. + */ + public static void upgradeActionFile( + ActionFile actionFile, + DownloadIndex downloadIndex, + @Nullable DownloadIdProvider downloadIdProvider) + throws IOException { + if (downloadIdProvider == null) { + downloadIdProvider = downloadAction -> downloadAction.id; + } + for (DownloadAction action : actionFile.load()) { + addAction(downloadIndex, downloadIdProvider.getId(action), action); + } + } + + /** + * Converts a {@link DownloadAction} to {@link DownloadState} and stored in the given {@link + * DownloadIndex}. + * + *

    This method shouldn't be called while {@link DownloadIndex} is used by {@link + * DownloadManager}. + * + * @param downloadIndex The action is converted to {@link DownloadState} and stored in this index. + * @param id A nullable custom download id which overwrites {@link DownloadAction#id}. + * @param action The action to be stored in {@link DownloadIndex}. + */ + public static void addAction( + DownloadIndex downloadIndex, @Nullable String id, DownloadAction action) { + DownloadState downloadState = downloadIndex.getDownloadState(id != null ? id : action.id); + if (downloadState != null) { + downloadState = merge(downloadState, action); + } else { + downloadState = convert(action); + } + downloadIndex.putDownloadState(downloadState); + } + + private static DownloadState merge(DownloadState downloadState, DownloadAction action) { + Assertions.checkArgument(action.type.equals(downloadState.type)); + @State int newState; + if (action.isRemoveAction) { + newState = DownloadState.STATE_REMOVING; + } else { + if (downloadState.state == DownloadState.STATE_REMOVING + || downloadState.state == DownloadState.STATE_RESTARTING) { + newState = DownloadState.STATE_RESTARTING; + } else if (downloadState.state == DownloadState.STATE_STOPPED) { + newState = DownloadState.STATE_STOPPED; + } else { + newState = DownloadState.STATE_QUEUED; + } + } + HashSet keys = new HashSet<>(action.keys); + Collections.addAll(keys, downloadState.streamKeys); + StreamKey[] newKeys = keys.toArray(new StreamKey[0]); + return new DownloadState( + downloadState.id, + downloadState.type, + action.uri, + action.customCacheKey, + newState, + /* downloadPercentage= */ C.PERCENTAGE_UNSET, + downloadState.downloadedBytes, + /* totalBytes= */ C.LENGTH_UNSET, + downloadState.failureReason, + downloadState.stopFlags, + downloadState.startTimeMs, + downloadState.updateTimeMs, + newKeys, + action.data); + } + + private static DownloadState convert(DownloadAction action) { + long currentTimeMs = System.currentTimeMillis(); + return new DownloadState( + action.id, + action.type, + action.uri, + action.customCacheKey, + /* state= */ action.isRemoveAction + ? DownloadState.STATE_REMOVING + : DownloadState.STATE_QUEUED, + /* downloadPercentage= */ C.PERCENTAGE_UNSET, + /* downloadedBytes= */ 0, + /* totalBytes= */ C.LENGTH_UNSET, + DownloadState.FAILURE_REASON_NONE, + /* stopFlags= */ 0, + /* startTimeMs= */ currentTimeMs, + /* updateTimeMs= */ currentTimeMs, + action.keys.toArray(new StreamKey[0]), + action.data); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 4a76c80d64..8932140a34 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -15,28 +15,34 @@ */ package com.google.android.exoplayer2.offline; -import static com.google.android.exoplayer2.offline.DownloadManager.TaskState.STATE_CANCELED; -import static com.google.android.exoplayer2.offline.DownloadManager.TaskState.STATE_COMPLETED; -import static com.google.android.exoplayer2.offline.DownloadManager.TaskState.STATE_FAILED; -import static com.google.android.exoplayer2.offline.DownloadManager.TaskState.STATE_QUEUED; -import static com.google.android.exoplayer2.offline.DownloadManager.TaskState.STATE_STARTED; +import static com.google.android.exoplayer2.offline.DownloadState.FAILURE_REASON_NONE; +import static com.google.android.exoplayer2.offline.DownloadState.FAILURE_REASON_UNKNOWN; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_COMPLETED; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_DOWNLOADING; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_FAILED; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_QUEUED; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_REMOVED; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_REMOVING; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_RESTARTING; +import static com.google.android.exoplayer2.offline.DownloadState.STATE_STOPPED; +import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY; +import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_STOPPED; +import android.content.Context; import android.os.ConditionVariable; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; -import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.scheduler.Requirements; +import com.google.android.exoplayer2.scheduler.RequirementsWatcher; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import java.io.File; import java.io.IOException; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -58,77 +64,106 @@ public final class DownloadManager { */ void onInitialized(DownloadManager downloadManager); /** - * Called when the state of a task changes. + * Called when the state of a download changes. * * @param downloadManager The reporting instance. - * @param taskState The state of the task. + * @param downloadState The state of the download. */ - void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState); + void onDownloadStateChanged(DownloadManager downloadManager, DownloadState downloadState); /** - * Called when there is no active task left. + * Called when there is no active download left. * * @param downloadManager The reporting instance. */ void onIdle(DownloadManager downloadManager); + + /** + * Called when the download requirements state changed. + * + * @param downloadManager The reporting instance. + * @param requirements Requirements needed to be met to start downloads. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. + */ + void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements); } - /** The default maximum number of simultaneous download tasks. */ + /** The default maximum number of simultaneous downloads. */ public static final int DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS = 1; - /** The default minimum number of times a task must be retried before failing. */ + /** The default minimum number of times a download must be retried before failing. */ public static final int DEFAULT_MIN_RETRY_COUNT = 5; + /** The default requirement is that the device has network connectivity. */ + public static final Requirements DEFAULT_REQUIREMENTS = + new Requirements(Requirements.NETWORK_TYPE_ANY, false, false); private static final String TAG = "DownloadManager"; private static final boolean DEBUG = false; - private final int maxActiveDownloadTasks; + private final int maxActiveDownloads; private final int minRetryCount; + private final Context context; private final ActionFile actionFile; private final DownloaderFactory downloaderFactory; - private final ArrayList tasks; - private final ArrayList activeDownloadTasks; + private final ArrayList downloads; + private final ArrayList activeDownloads; private final Handler handler; private final HandlerThread fileIOThread; private final Handler fileIOHandler; private final CopyOnWriteArraySet listeners; + private final ArrayDeque actionQueue; - private int nextTaskId; private boolean initialized; private boolean released; - private boolean downloadsStopped; + @DownloadState.StopFlags private int stickyStopFlags; + private RequirementsWatcher requirementsWatcher; /** * Constructs a {@link DownloadManager}. * + * @param context Any context. * @param actionFile The file in which active actions are saved. * @param downloaderFactory A factory for creating {@link Downloader}s. */ - public DownloadManager(File actionFile, DownloaderFactory downloaderFactory) { + public DownloadManager(Context context, File actionFile, DownloaderFactory downloaderFactory) { this( - actionFile, downloaderFactory, DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS, DEFAULT_MIN_RETRY_COUNT); + context, + actionFile, + downloaderFactory, + DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS, + DEFAULT_MIN_RETRY_COUNT, + DEFAULT_REQUIREMENTS); } /** * Constructs a {@link DownloadManager}. * + * @param context Any context. * @param actionFile The file in which active actions are saved. * @param downloaderFactory A factory for creating {@link Downloader}s. - * @param maxSimultaneousDownloads The maximum number of simultaneous download tasks. - * @param minRetryCount The minimum number of times a task must be retried before failing. + * @param maxSimultaneousDownloads The maximum number of simultaneous downloads. + * @param minRetryCount The minimum number of times a download must be retried before failing. + * @param requirements The requirements needed to be met to start downloads. */ public DownloadManager( + Context context, File actionFile, DownloaderFactory downloaderFactory, int maxSimultaneousDownloads, - int minRetryCount) { + int minRetryCount, + Requirements requirements) { + this.context = context.getApplicationContext(); this.actionFile = new ActionFile(actionFile); this.downloaderFactory = downloaderFactory; - this.maxActiveDownloadTasks = maxSimultaneousDownloads; + this.maxActiveDownloads = maxSimultaneousDownloads; this.minRetryCount = minRetryCount; - this.downloadsStopped = true; + this.stickyStopFlags = STOP_FLAG_STOPPED | STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY; - tasks = new ArrayList<>(); - activeDownloadTasks = new ArrayList<>(); + downloads = new ArrayList<>(); + activeDownloads = new ArrayList<>(); Looper looper = Looper.myLooper(); if (looper == null) { @@ -141,11 +176,32 @@ public final class DownloadManager { fileIOHandler = new Handler(fileIOThread.getLooper()); listeners = new CopyOnWriteArraySet<>(); + actionQueue = new ArrayDeque<>(); + watchRequirements(requirements); loadActions(); logd("Created"); } + /** + * Sets the requirements needed to be met to start downloads. + * + * @param requirements Need to be met to start downloads. + */ + public void setRequirements(Requirements requirements) { + Assertions.checkState(!released); + if (requirements.equals(requirementsWatcher.getRequirements())) { + return; + } + requirementsWatcher.stop(); + notifyListenersRequirementsStateChange(watchRequirements(requirements)); + } + + /** Returns the requirements needed to be met to start downloads. */ + public Requirements getRequirements() { + return requirementsWatcher.getRequirements(); + } + /** * Adds a {@link Listener}. * @@ -164,85 +220,81 @@ public final class DownloadManager { listeners.remove(listener); } - /** Starts the download tasks. */ + /** Starts the downloads. */ public void startDownloads() { - Assertions.checkState(!released); - if (downloadsStopped) { - downloadsStopped = false; - maybeStartTasks(); - logd("Downloads are started"); - } + clearStopFlags(STOP_FLAG_STOPPED); } - /** Stops all of the download tasks. Call {@link #startDownloads()} to restart tasks. */ + /** Stops all of the downloads. Call {@link #startDownloads()} to restart downloads. */ public void stopDownloads() { + setStopFlags(STOP_FLAG_STOPPED); + } + + private void setStopFlags(int flags) { + updateStopFlags(flags, flags); + } + + private void clearStopFlags(int flags) { + updateStopFlags(flags, 0); + } + + private void updateStopFlags(int flags, int values) { Assertions.checkState(!released); - if (!downloadsStopped) { - downloadsStopped = true; - for (int i = 0; i < activeDownloadTasks.size(); i++) { - activeDownloadTasks.get(i).stop(); + int updatedStickyStopFlags = (values & flags) | (stickyStopFlags & ~flags); + if (stickyStopFlags != updatedStickyStopFlags) { + stickyStopFlags = updatedStickyStopFlags; + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).updateStopFlags(flags, values); } - logd("Downloads are stopping"); + logdFlags("Sticky stop flags are updated", updatedStickyStopFlags); } } /** - * Handles the given action. A task is created and added to the task queue. If it's a remove - * action then any download tasks for the same media are immediately canceled. + * Handles the given action. * * @param action The action to be executed. - * @return The id of the newly created task. */ - public int handleAction(DownloadAction action) { + public void handleAction(DownloadAction action) { Assertions.checkState(!released); - Task task = addTaskForAction(action); if (initialized) { + addDownloadForAction(action); saveActions(); - maybeStartTasks(); - if (task.state == STATE_QUEUED) { - // Task did not change out of its initial state, and so its initial state won't have been - // reported to listeners. Do so now. - notifyListenersTaskStateChange(task); - } + } else { + actionQueue.add(action); } - return task.id; } - /** Returns the number of tasks. */ - public int getTaskCount() { - Assertions.checkState(!released); - return tasks.size(); - } - - /** Returns the number of download tasks. */ + /** Returns the number of downloads. */ public int getDownloadCount() { - int count = 0; - for (int i = 0; i < tasks.size(); i++) { - if (!tasks.get(i).action.isRemoveAction) { - count++; - } - } - return count; + Assertions.checkState(!released); + return downloads.size(); } - /** Returns the state of a task, or null if no such task exists */ - public @Nullable TaskState getTaskState(int taskId) { + /** + * Returns {@link DownloadState} for the given content id, or null if no such download exists. + * + * @param id The unique content id. + * @return DownloadState for the given content id, or null if no such download exists. + */ + @Nullable + public DownloadState getDownloadState(String id) { Assertions.checkState(!released); - for (int i = 0; i < tasks.size(); i++) { - Task task = tasks.get(i); - if (task.id == taskId) { - return task.getTaskState(); + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.id.equals(id)) { + return download.getDownloadState(); } } return null; } - /** Returns the states of all current tasks. */ - public TaskState[] getAllTaskStates() { + /** Returns the states of all current downloads. */ + public DownloadState[] getAllDownloadStates() { Assertions.checkState(!released); - TaskState[] states = new TaskState[tasks.size()]; + DownloadState[] states = new DownloadState[downloads.size()]; for (int i = 0; i < states.length; i++) { - states[i] = tasks.get(i).getTaskState(); + states[i] = downloads.get(i).getDownloadState(); } return states; } @@ -253,14 +305,14 @@ public final class DownloadManager { return initialized; } - /** Returns whether there are no active tasks. */ + /** Returns whether there are no active downloads. */ public boolean isIdle() { Assertions.checkState(!released); if (!initialized) { return false; } - for (int i = 0; i < tasks.size(); i++) { - if (tasks.get(i).isStarted()) { + for (int i = 0; i < downloads.size(); i++) { + if (!downloads.get(i).isIdle()) { return false; } } @@ -268,16 +320,18 @@ public final class DownloadManager { } /** - * Stops all of the tasks and releases resources. If the action file isn't up to date, waits for - * the changes to be written. The manager must not be accessed after this method has been called. + * Stops all of the downloads and releases resources. If the action file isn't up to date, waits + * for the changes to be written. The manager must not be accessed after this method has been + * called. */ public void release() { if (released) { return; } + setStopFlags(STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY); released = true; - for (int i = 0; i < tasks.size(); i++) { - tasks.get(i).stop(); + if (requirementsWatcher != null) { + requirementsWatcher.stop(); } final ConditionVariable fileIOFinishedCondition = new ConditionVariable(); fileIOHandler.post(fileIOFinishedCondition::open); @@ -286,67 +340,24 @@ public final class DownloadManager { logd("Released"); } - private Task addTaskForAction(DownloadAction action) { - Task task = new Task(nextTaskId++, this, downloaderFactory, action, minRetryCount); - tasks.add(task); - logd("Task is added", task); - return task; + private void addDownloadForAction(DownloadAction action) { + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.addAction(action)) { + logd("Action is added to existing download", download); + return; + } + } + Download download = + new Download(this, downloaderFactory, action, minRetryCount, stickyStopFlags); + downloads.add(download); + logd("Download is added", download); } - /** - * Iterates through the task queue and starts any task if all of the following are true: - * - *

      - *
    • It hasn't started yet. - *
    • There are no preceding conflicting tasks. - *
    • If it's a download task then there are no preceding download tasks on hold and the - * maximum number of active downloads hasn't been reached. - *
    - * - * If the task is a remove action then preceding conflicting tasks are canceled. - */ - private void maybeStartTasks() { - if (!initialized || released) { - return; - } - - boolean skipDownloadActions = downloadsStopped - || activeDownloadTasks.size() == maxActiveDownloadTasks; - for (int i = 0; i < tasks.size(); i++) { - Task task = tasks.get(i); - if (!task.canStart()) { - continue; - } - - DownloadAction action = task.action; - boolean isRemoveAction = action.isRemoveAction; - if (!isRemoveAction && skipDownloadActions) { - continue; - } - - boolean canStartTask = true; - for (int j = 0; j < i; j++) { - Task otherTask = tasks.get(j); - if (otherTask.action.isSameMedia(action)) { - if (isRemoveAction) { - canStartTask = false; - logd(task + " clashes with " + otherTask); - otherTask.cancel(); - // Continue loop to cancel any other preceding clashing tasks. - } else if (otherTask.action.isRemoveAction) { - canStartTask = false; - skipDownloadActions = true; - break; - } - } - } - - if (canStartTask) { - task.start(); - if (!isRemoveAction) { - activeDownloadTasks.add(task); - skipDownloadActions = activeDownloadTasks.size() == maxActiveDownloadTasks; - } + private void maybeStartDownload(Download download) { + if (activeDownloads.size() < maxActiveDownloads) { + if (download.start()) { + activeDownloads.add(download); } } } @@ -361,30 +372,41 @@ public final class DownloadManager { } } - private void onTaskStateChange(Task task) { + private void onDownloadStateChange(Download download) { if (released) { return; } - boolean stopped = !task.isStarted(); - if (stopped) { - activeDownloadTasks.remove(task); + boolean idle = download.isIdle(); + if (idle) { + activeDownloads.remove(download); } - notifyListenersTaskStateChange(task); - if (task.isFinished()) { - tasks.remove(task); + notifyListenersDownloadStateChange(download); + if (download.isFinished()) { + downloads.remove(download); saveActions(); } - if (stopped) { - maybeStartTasks(); + if (idle) { + for (int i = 0; i < downloads.size(); i++) { + maybeStartDownload(downloads.get(i)); + } maybeNotifyListenersIdle(); } } - private void notifyListenersTaskStateChange(Task task) { - logd("Task state is changed", task); - TaskState taskState = task.getTaskState(); + private void notifyListenersDownloadStateChange(Download download) { + logd("Download state is changed", download); + DownloadState downloadState = download.getDownloadState(); for (Listener listener : listeners) { - listener.onTaskStateChanged(this, taskState); + listener.onDownloadStateChanged(this, downloadState); + } + } + + private void notifyListenersRequirementsStateChange( + @Requirements.RequirementFlags int notMetRequirements) { + logdFlags("Not met requirements are changed", notMetRequirements); + for (Listener listener : listeners) { + listener.onRequirementsStateChanged( + DownloadManager.this, requirementsWatcher.getRequirements(), notMetRequirements); } } @@ -405,29 +427,21 @@ public final class DownloadManager { if (released) { return; } - List pendingTasks = new ArrayList<>(tasks); - tasks.clear(); for (DownloadAction action : actions) { - addTaskForAction(action); + addDownloadForAction(action); } - logd("Tasks are created."); + if (!actionQueue.isEmpty()) { + while (!actionQueue.isEmpty()) { + addDownloadForAction(actionQueue.remove()); + } + saveActions(); + } + logd("Downloads are created."); initialized = true; for (Listener listener : listeners) { listener.onInitialized(DownloadManager.this); } - if (!pendingTasks.isEmpty()) { - tasks.addAll(pendingTasks); - saveActions(); - } - maybeStartTasks(); - for (int i = 0; i < tasks.size(); i++) { - Task task = tasks.get(i); - if (task.state == STATE_QUEUED) { - // Task did not change out of its initial state, and so its initial state - // won't have been reported to listeners. Do so now. - notifyListenersTaskStateChange(task); - } - } + clearStopFlags(STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY); }); }); } @@ -436,14 +450,15 @@ public final class DownloadManager { if (released) { return; } - final DownloadAction[] actions = new DownloadAction[tasks.size()]; - for (int i = 0; i < tasks.size(); i++) { - actions[i] = tasks.get(i).action; + ArrayList actions = new ArrayList<>(downloads.size()); + for (int i = 0; i < downloads.size(); i++) { + actions.addAll(downloads.get(i).actionQueue); } + final DownloadAction[] actionsArray = actions.toArray(new DownloadAction[0]); fileIOHandler.post( () -> { try { - actionFile.store(actions); + actionFile.store(actionsArray); logd("Actions persisted."); } catch (IOException e) { Log.e(TAG, "Persisting actions failed.", e); @@ -457,242 +472,287 @@ public final class DownloadManager { } } - private static void logd(String message, Task task) { - logd(message + ": " + task); + private static void logd(String message, Download download) { + if (DEBUG) { + logd(message + ": " + download); + } } - /** Represents state of a task. */ - public static final class TaskState { - - /** - * Task states. One of {@link #STATE_QUEUED}, {@link #STATE_STARTED}, {@link #STATE_COMPLETED}, - * {@link #STATE_CANCELED} or {@link #STATE_FAILED}. - * - *

    Transition diagram: - * - *

    -     *    ┌────────┬─────→ canceled
    -     * queued ↔ started ┬→ completed
    -     *                  └→ failed
    -     * 
    - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_QUEUED, STATE_STARTED, STATE_COMPLETED, STATE_CANCELED, STATE_FAILED}) - public @interface State {} - /** The task is waiting to be started. */ - public static final int STATE_QUEUED = 0; - /** The task is currently started. */ - public static final int STATE_STARTED = 1; - /** The task completed. */ - public static final int STATE_COMPLETED = 2; - /** The task was canceled. */ - public static final int STATE_CANCELED = 3; - /** The task failed. */ - public static final int STATE_FAILED = 4; - - /** Returns the state string for the given state value. */ - public static String getStateString(@State int state) { - switch (state) { - case STATE_QUEUED: - return "QUEUED"; - case STATE_STARTED: - return "STARTED"; - case STATE_COMPLETED: - return "COMPLETED"; - case STATE_CANCELED: - return "CANCELED"; - case STATE_FAILED: - return "FAILED"; - default: - throw new IllegalStateException(); - } + private static void logdFlags(String message, int flags) { + if (DEBUG) { + logd(message + ": " + Integer.toBinaryString(flags)); } - - /** The unique task id. */ - public final int taskId; - /** The action being executed. */ - public final DownloadAction action; - /** The state of the task. */ - public final @State int state; - - /** - * The estimated download percentage, or {@link C#PERCENTAGE_UNSET} if no estimate is available - * or if this is a removal task. - */ - public final float downloadPercentage; - /** The total number of downloaded bytes. */ - public final long downloadedBytes; - - /** If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise null. */ - @Nullable public final Throwable error; - - private TaskState( - int taskId, - DownloadAction action, - @State int state, - float downloadPercentage, - long downloadedBytes, - @Nullable Throwable error) { - this.taskId = taskId; - this.action = action; - this.state = state; - this.downloadPercentage = downloadPercentage; - this.downloadedBytes = downloadedBytes; - this.error = error; - } - } - private static final class Task implements Runnable { + @Requirements.RequirementFlags + private int watchRequirements(Requirements requirements) { + requirementsWatcher = new RequirementsWatcher(context, new RequirementListener(), requirements); + @Requirements.RequirementFlags int notMetRequirements = requirementsWatcher.start(); + if (notMetRequirements == 0) { + startDownloads(); + } else { + stopDownloads(); + } + return notMetRequirements; + } - /** Target states for the download thread. */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_COMPLETED, STATE_QUEUED, STATE_CANCELED}) - public @interface TargetState {} + private static final class Download { - private final int id; + private final String id; private final DownloadManager downloadManager; private final DownloaderFactory downloaderFactory; - private final DownloadAction action; private final int minRetryCount; - /** The current state of the task. */ - @TaskState.State private int state; - /** - * When started, this is the target state that the task will transition to when the download - * thread stops. - */ - @TargetState private volatile int targetState; + private final long startTimeMs; + private final ArrayDeque actionQueue; + /** The current state of the download. */ + @DownloadState.State private int state; @MonotonicNonNull private Downloader downloader; - @MonotonicNonNull private Thread thread; - @MonotonicNonNull private Throwable error; + @MonotonicNonNull private DownloadThread downloadThread; + @MonotonicNonNull @DownloadState.FailureReason private int failureReason; + @DownloadState.StopFlags private int stopFlags; - private Task( - int id, + private Download( DownloadManager downloadManager, DownloaderFactory downloaderFactory, DownloadAction action, - int minRetryCount) { - this.id = id; + int minRetryCount, + int stopFlags) { + this.id = action.id; this.downloadManager = downloadManager; this.downloaderFactory = downloaderFactory; - this.action = action; this.minRetryCount = minRetryCount; - state = STATE_QUEUED; - targetState = STATE_COMPLETED; + this.stopFlags = stopFlags; + this.startTimeMs = System.currentTimeMillis(); + actionQueue = new ArrayDeque<>(); + actionQueue.add(action); + initialize(/* restart= */ false); } - public TaskState getTaskState() { + public boolean addAction(DownloadAction newAction) { + DownloadAction action = actionQueue.peek(); + if (!action.isSameMedia(newAction)) { + return false; + } + Assertions.checkState(action.type.equals(newAction.type)); + actionQueue.add(newAction); + DownloadAction updatedAction = DownloadActionUtil.mergeActions(actionQueue); + if (state == STATE_REMOVING) { + Assertions.checkState(updatedAction.isRemoveAction); + if (actionQueue.size() > 1) { + setState(STATE_RESTARTING); + } + } else if (state == STATE_RESTARTING) { + Assertions.checkState(updatedAction.isRemoveAction); + if (actionQueue.size() == 1) { + setState(STATE_REMOVING); + } + } else if (!action.equals(updatedAction)) { + if (state == STATE_DOWNLOADING) { + stopDownloadThread(); + } else { + Assertions.checkState(state == STATE_QUEUED || state == STATE_STOPPED); + initialize(/* restart= */ false); + } + } + return true; + } + + public DownloadState getDownloadState() { float downloadPercentage = C.PERCENTAGE_UNSET; long downloadedBytes = 0; + long totalBytes = C.LENGTH_UNSET; if (downloader != null) { downloadPercentage = downloader.getDownloadPercentage(); downloadedBytes = downloader.getDownloadedBytes(); + totalBytes = downloader.getTotalBytes(); } - return new TaskState(id, action, state, downloadPercentage, downloadedBytes, error); + DownloadAction action = actionQueue.peek(); + return new DownloadState( + action.id, + action.type, + action.uri, + action.customCacheKey, + state, + downloadPercentage, + downloadedBytes, + totalBytes, + failureReason, + stopFlags, + startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + action.keys.toArray(new StreamKey[0]), + action.data); } - /** Returns whether the task is finished. */ public boolean isFinished() { - return state == STATE_FAILED || state == STATE_COMPLETED || state == STATE_CANCELED; + return state == STATE_FAILED || state == STATE_COMPLETED || state == STATE_REMOVED; } - /** Returns whether the task is started. */ - public boolean isStarted() { - return state == STATE_STARTED; + public boolean isIdle() { + return state != STATE_DOWNLOADING && state != STATE_REMOVING && state != STATE_RESTARTING; } @Override public String toString() { - return action.type - + ' ' - + (action.isRemoveAction ? "remove" : "download") - + ' ' - + TaskState.getStateString(state) - + ' ' - + TaskState.getStateString(targetState); + return id + ' ' + DownloadState.getStateString(state); } - public boolean canStart() { - return state == STATE_QUEUED; + public boolean start() { + if (state != STATE_QUEUED) { + return false; + } + startDownloadThread(actionQueue.peek()); + setState(STATE_DOWNLOADING); + return true; } - public void start() { + public void setStopFlags(int flags) { + updateStopFlags(flags, flags); + } + + public void clearStopFlags(int flags) { + updateStopFlags(flags, 0); + } + + public void updateStopFlags(int flags, int values) { + stopFlags = (values & flags) | (stopFlags & ~flags); + if (stopFlags != 0) { + if (state == STATE_DOWNLOADING) { + stopDownloadThread(); + } else if (state == STATE_QUEUED) { + setState(STATE_STOPPED); + } + } else if (state == STATE_STOPPED) { + startOrQueue(/* restart= */ false); + } + } + + private void initialize(boolean restart) { + DownloadAction action = actionQueue.peek(); + if (action.isRemoveAction) { + if (!downloadManager.released) { + startDownloadThread(action); + } + setState(actionQueue.size() == 1 ? STATE_REMOVING : STATE_RESTARTING); + } else if (stopFlags != 0) { + setState(STATE_STOPPED); + } else { + startOrQueue(restart); + } + } + + private void startOrQueue(boolean restart) { + // Set to queued state but don't notify listeners until we make sure we can't start now. + state = STATE_QUEUED; + if (restart) { + start(); + } else { + downloadManager.maybeStartDownload(this); + } if (state == STATE_QUEUED) { - state = STATE_STARTED; - targetState = STATE_COMPLETED; - downloadManager.onTaskStateChange(this); - downloader = downloaderFactory.createDownloader(action); - thread = new Thread(this); - thread.start(); + downloadManager.onDownloadStateChange(this); } } - public void cancel() { - if (state == STATE_STARTED) { - stopDownloadThread(STATE_CANCELED); - } else if (state == STATE_QUEUED) { - state = STATE_CANCELED; - downloadManager.handler.post(() -> downloadManager.onTaskStateChange(this)); - } + private void setState(@DownloadState.State int newState) { + state = newState; + downloadManager.onDownloadStateChange(this); } - public void stop() { - if (state == STATE_STARTED && targetState == STATE_COMPLETED) { - stopDownloadThread(STATE_QUEUED); - } + private void startDownloadThread(DownloadAction action) { + downloader = downloaderFactory.createDownloader(action); + downloadThread = + new DownloadThread( + this, downloader, action.isRemoveAction, minRetryCount, downloadManager.handler); } - // Internal methods running on the main thread. - - private void stopDownloadThread(@TargetState int targetState) { - this.targetState = targetState; - Assertions.checkNotNull(downloader).cancel(); - Assertions.checkNotNull(thread).interrupt(); + private void stopDownloadThread() { + Assertions.checkNotNull(downloadThread).cancel(); } private void onDownloadThreadStopped(@Nullable Throwable finalError) { - @TaskState.State int finalState = targetState; - if (targetState == STATE_COMPLETED && finalError != null) { - finalState = STATE_FAILED; - } else { - finalError = null; + failureReason = FAILURE_REASON_NONE; + if (!downloadThread.isCanceled) { + if (finalError != null && state != STATE_REMOVING && state != STATE_RESTARTING) { + failureReason = FAILURE_REASON_UNKNOWN; + setState(STATE_FAILED); + return; + } + if (actionQueue.size() == 1) { + if (state == STATE_REMOVING) { + setState(STATE_REMOVED); + } else { + Assertions.checkState(state == STATE_DOWNLOADING); + setState(STATE_COMPLETED); + } + return; + } + actionQueue.remove(); } - state = finalState; - error = finalError; - downloadManager.onTaskStateChange(this); + initialize(/* restart= */ state == STATE_DOWNLOADING); + } + } + + private static class DownloadThread implements Runnable { + + private final Download download; + private final Downloader downloader; + private final boolean remove; + private final int minRetryCount; + private final Handler callbackHandler; + private final Thread thread; + private volatile boolean isCanceled; + + private DownloadThread( + Download download, + Downloader downloader, + boolean remove, + int minRetryCount, + Handler callbackHandler) { + this.download = download; + this.downloader = downloader; + this.remove = remove; + this.minRetryCount = minRetryCount; + this.callbackHandler = callbackHandler; + thread = new Thread(this); + thread.start(); + } + + public void cancel() { + isCanceled = true; + downloader.cancel(); + thread.interrupt(); } // Methods running on download thread. @Override public void run() { - logd("Task is started", this); + logd("Download is started", download); Throwable error = null; try { - if (action.isRemoveAction) { + if (remove) { downloader.remove(); } else { int errorCount = 0; long errorPosition = C.LENGTH_UNSET; - while (targetState == STATE_COMPLETED) { + while (!isCanceled) { try { downloader.download(); break; } catch (IOException e) { - if (targetState == STATE_COMPLETED) { + if (!isCanceled) { long downloadedBytes = downloader.getDownloadedBytes(); if (downloadedBytes != errorPosition) { - logd("Reset error count. downloadedBytes = " + downloadedBytes, this); + logd("Reset error count. downloadedBytes = " + downloadedBytes, download); errorPosition = downloadedBytes; errorCount = 0; } if (++errorCount > minRetryCount) { throw e; } - logd("Download error. Retry " + errorCount, this); + logd("Download error. Retry " + errorCount, download); Thread.sleep(getRetryDelayMillis(errorCount)); } } @@ -702,7 +762,7 @@ public final class DownloadManager { error = e; } final Throwable finalError = error; - downloadManager.handler.post(() -> onDownloadThreadStopped(finalError)); + callbackHandler.post(() -> download.onDownloadThreadStopped(isCanceled ? null : finalError)); } private int getRetryDelayMillis(int errorCount) { @@ -710,4 +770,19 @@ public final class DownloadManager { } } + private class RequirementListener implements RequirementsWatcher.Listener { + @Override + public void requirementsMet(RequirementsWatcher requirementsWatcher) { + startDownloads(); + notifyListenersRequirementsStateChange(0); + } + + @Override + public void requirementsNotMet( + RequirementsWatcher requirementsWatcher, + @Requirements.RequirementFlags int notMetRequirements) { + stopDownloads(); + notifyListenersRequirementsStateChange(notMetRequirements); + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index cfca8ede79..d424ed5ef0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -24,10 +24,9 @@ import android.os.IBinder; import android.os.Looper; import android.support.annotation.Nullable; import android.support.annotation.StringRes; -import com.google.android.exoplayer2.offline.DownloadManager.TaskState; import com.google.android.exoplayer2.scheduler.Requirements; -import com.google.android.exoplayer2.scheduler.RequirementsWatcher; import com.google.android.exoplayer2.scheduler.Scheduler; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.NotificationUtil; import com.google.android.exoplayer2.util.Util; @@ -44,10 +43,6 @@ public abstract class DownloadService extends Service { /** Starts a download service, adding a new {@link DownloadAction} to be executed. */ public static final String ACTION_ADD = "com.google.android.exoplayer.downloadService.action.ADD"; - /** Reloads the download requirements. */ - public static final String ACTION_RELOAD_REQUIREMENTS = - "com.google.android.exoplayer.downloadService.action.RELOAD_REQUIREMENTS"; - /** Like {@link #ACTION_INIT}, but with {@link #KEY_FOREGROUND} implicitly set to true. */ private static final String ACTION_RESTART = "com.google.android.exoplayer.downloadService.action.RESTART"; @@ -71,20 +66,16 @@ public abstract class DownloadService extends Service { private static final String TAG = "DownloadService"; private static final boolean DEBUG = false; - // Keep the requirements helper for each DownloadService as long as there are tasks (and the - // process is running). This allows tasks to resume when there's no scheduler. It may also allow - // tasks the resume more quickly than when relying on the scheduler alone. - private static final HashMap, RequirementsHelper> - requirementsHelpers = new HashMap<>(); - private static final Requirements DEFAULT_REQUIREMENTS = - new Requirements(Requirements.NETWORK_TYPE_ANY, false, false); + // Keep DownloadManagerListeners for each DownloadService as long as there are downloads (and the + // process is running). This allows DownloadService to restart when there's no scheduler. + private static final HashMap, DownloadManagerHelper> + downloadManagerListeners = new HashMap<>(); private final @Nullable ForegroundNotificationUpdater foregroundNotificationUpdater; private final @Nullable String channelId; private final @StringRes int channelName; private DownloadManager downloadManager; - private DownloadManagerListener downloadManagerListener; private int lastStartId; private boolean startedInForeground; private boolean taskRemoved; @@ -99,7 +90,7 @@ public abstract class DownloadService extends Service { *

    If {@code foregroundNotificationId} isn't {@link #FOREGROUND_NOTIFICATION_ID_NONE} (value * {@value #FOREGROUND_NOTIFICATION_ID_NONE}) the service runs in the foreground with {@link * #DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL}. In that case {@link - * #getForegroundNotification(TaskState[])} should be overridden in the subclass. + * #getForegroundNotification(DownloadState[])} should be overridden in the subclass. * * @param foregroundNotificationId The notification id for the foreground notification, or {@link * #FOREGROUND_NOTIFICATION_ID_NONE} (value {@value #FOREGROUND_NOTIFICATION_ID_NONE}) @@ -110,7 +101,7 @@ public abstract class DownloadService extends Service { /** * Creates a DownloadService which will run in the foreground. {@link - * #getForegroundNotification(TaskState[])} should be overridden in the subclass. + * #getForegroundNotification(DownloadState[])} should be overridden in the subclass. * * @param foregroundNotificationId The notification id for the foreground notification, must not * be 0. @@ -128,7 +119,7 @@ public abstract class DownloadService extends Service { /** * Creates a DownloadService which will run in the foreground. {@link - * #getForegroundNotification(TaskState[])} should be overridden in the subclass. + * #getForegroundNotification(DownloadState[])} should be overridden in the subclass. * * @param foregroundNotificationId The notification id for the foreground notification. Must not * be 0. @@ -228,9 +219,16 @@ public abstract class DownloadService extends Service { NotificationUtil.createNotificationChannel( this, channelId, channelName, NotificationUtil.IMPORTANCE_LOW); } - downloadManager = getDownloadManager(); - downloadManagerListener = new DownloadManagerListener(); - downloadManager.addListener(downloadManagerListener); + Class clazz = getClass(); + DownloadManagerHelper downloadManagerHelper = downloadManagerListeners.get(clazz); + if (downloadManagerHelper == null) { + downloadManagerHelper = + new DownloadManagerHelper( + getApplicationContext(), getDownloadManager(), getScheduler(), clazz); + downloadManagerListeners.put(clazz, downloadManagerHelper); + } + downloadManager = downloadManagerHelper.downloadManager; + downloadManagerHelper.attachService(this); } @Override @@ -265,22 +263,11 @@ public abstract class DownloadService extends Service { } } break; - case ACTION_RELOAD_REQUIREMENTS: - stopWatchingRequirements(); - break; default: Log.e(TAG, "Ignoring unrecognized action: " + intentAction); break; } - Requirements requirements = getRequirements(); - if (requirements.checkRequirements(this)) { - downloadManager.startDownloads(); - } else { - downloadManager.stopDownloads(); - } - maybeStartWatchingRequirements(requirements); - if (downloadManager.isIdle()) { stop(); } @@ -296,11 +283,12 @@ public abstract class DownloadService extends Service { @Override public void onDestroy() { logd("onDestroy"); + DownloadManagerHelper downloadManagerHelper = downloadManagerListeners.get(getClass()); + boolean unschedule = downloadManager.getDownloadCount() <= 0; + downloadManagerHelper.detachService(this, unschedule); if (foregroundNotificationUpdater != null) { foregroundNotificationUpdater.stopPeriodicUpdates(); } - downloadManager.removeListener(downloadManagerListener); - maybeStopWatchingRequirements(); } /** DownloadService isn't designed to be bound. */ @@ -312,9 +300,7 @@ public abstract class DownloadService extends Service { /** * Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the - * life cycle of the service. The service will call {@link DownloadManager#startDownloads()} and - * {@link DownloadManager#stopDownloads} as necessary when requirements returned by {@link - * #getRequirements()} are met or stop being met. + * life cycle of the process. */ protected abstract DownloadManager getDownloadManager(); @@ -325,71 +311,47 @@ public abstract class DownloadService extends Service { */ protected abstract @Nullable Scheduler getScheduler(); - /** - * Returns requirements for downloads to take place. By default the only requirement is that the - * device has network connectivity. - */ - protected Requirements getRequirements() { - return DEFAULT_REQUIREMENTS; - } - /** * Should be overridden in the subclass if the service will be run in the foreground. * *

    Returns a notification to be displayed when this service running in the foreground. * - *

    This method is called when there is a task state change and periodically while there are - * active tasks. The periodic update interval can be set using {@link #DownloadService(int, + *

    This method is called when there is a download state change and periodically while there are + * active downloads. The periodic update interval can be set using {@link #DownloadService(int, * long)}. * *

    On API level 26 and above, this method may also be called just before the service stops, - * with an empty {@code taskStates} array. The returned notification is used to satisfy system + * with an empty {@code downloadStates} array. The returned notification is used to satisfy system * requirements for foreground services. * - * @param taskStates The states of all current tasks. + * @param downloadStates The states of all current downloads. * @return The foreground notification to display. */ - protected Notification getForegroundNotification(TaskState[] taskStates) { + protected Notification getForegroundNotification(DownloadState[] downloadStates) { throw new IllegalStateException( getClass().getName() + " is started in the foreground but getForegroundNotification() is not implemented."); } /** - * Called when the state of a task changes. + * Called when the state of a download changes. * - * @param taskState The state of the task. + * @param downloadState The state of the download. */ - protected void onTaskStateChanged(TaskState taskState) { + protected void onDownloadStateChanged(DownloadState downloadState) { // Do nothing. } - private void maybeStartWatchingRequirements(Requirements requirements) { - if (downloadManager.getDownloadCount() == 0) { - return; - } - Class clazz = getClass(); - RequirementsHelper requirementsHelper = requirementsHelpers.get(clazz); - if (requirementsHelper == null) { - requirementsHelper = new RequirementsHelper(this, requirements, getScheduler(), clazz); - requirementsHelpers.put(clazz, requirementsHelper); - requirementsHelper.start(); - logd("started watching requirements"); - } - } - - private void maybeStopWatchingRequirements() { - if (downloadManager.getDownloadCount() > 0) { - return; - } - stopWatchingRequirements(); - } - - private void stopWatchingRequirements() { - RequirementsHelper requirementsHelper = requirementsHelpers.remove(getClass()); - if (requirementsHelper != null) { - requirementsHelper.stop(); - logd("stopped watching requirements"); + private void notifyDownloadStateChange(DownloadState downloadState) { + onDownloadStateChanged(downloadState); + if (foregroundNotificationUpdater != null) { + if (downloadState.state == DownloadState.STATE_DOWNLOADING + || downloadState.state == DownloadState.STATE_REMOVING + || downloadState.state == DownloadState.STATE_RESTARTING) { + foregroundNotificationUpdater.startPeriodicUpdates(); + } else { + foregroundNotificationUpdater.update(); + } } } @@ -421,30 +383,6 @@ public abstract class DownloadService extends Service { return new Intent(context, clazz).setAction(action); } - private final class DownloadManagerListener implements DownloadManager.Listener { - @Override - public void onInitialized(DownloadManager downloadManager) { - maybeStartWatchingRequirements(getRequirements()); - } - - @Override - public void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState) { - DownloadService.this.onTaskStateChanged(taskState); - if (foregroundNotificationUpdater != null) { - if (taskState.state == TaskState.STATE_STARTED) { - foregroundNotificationUpdater.startPeriodicUpdates(); - } else { - foregroundNotificationUpdater.update(); - } - } - } - - @Override - public final void onIdle(DownloadManager downloadManager) { - stop(); - } - } - private final class ForegroundNotificationUpdater implements Runnable { private final int notificationId; @@ -471,8 +409,8 @@ public abstract class DownloadService extends Service { } public void update() { - TaskState[] taskStates = downloadManager.getAllTaskStates(); - startForeground(notificationId, getForegroundNotification(taskStates)); + DownloadState[] downloadStates = downloadManager.getAllDownloadStates(); + startForeground(notificationId, getForegroundNotification(downloadStates)); notificationDisplayed = true; if (periodicUpdatesStarted) { handler.removeCallbacks(this); @@ -492,58 +430,87 @@ public abstract class DownloadService extends Service { } } - private static final class RequirementsHelper implements RequirementsWatcher.Listener { + private static final class DownloadManagerHelper implements DownloadManager.Listener { private final Context context; - private final Requirements requirements; - private final @Nullable Scheduler scheduler; + private final DownloadManager downloadManager; + @Nullable private final Scheduler scheduler; private final Class serviceClass; - private final RequirementsWatcher requirementsWatcher; + @Nullable private DownloadService downloadService; - private RequirementsHelper( + private DownloadManagerHelper( Context context, - Requirements requirements, + DownloadManager downloadManager, @Nullable Scheduler scheduler, Class serviceClass) { this.context = context; - this.requirements = requirements; + this.downloadManager = downloadManager; this.scheduler = scheduler; this.serviceClass = serviceClass; - requirementsWatcher = new RequirementsWatcher(context, this, requirements); - } - - public void start() { - requirementsWatcher.start(); - } - - public void stop() { - requirementsWatcher.stop(); + downloadManager.addListener(this); if (scheduler != null) { + Requirements requirements = downloadManager.getRequirements(); + setSchedulerEnabled(/* enabled= */ !requirements.checkRequirements(context), requirements); + } + } + + public void attachService(DownloadService downloadService) { + Assertions.checkState(this.downloadService == null); + this.downloadService = downloadService; + } + + public void detachService(DownloadService downloadService, boolean unschedule) { + Assertions.checkState(this.downloadService == downloadService); + this.downloadService = null; + if (unschedule) { scheduler.cancel(); } } @Override - public void requirementsMet(RequirementsWatcher requirementsWatcher) { - try { - notifyService(); - } catch (Exception e) { - /* If we can't notify the service, don't stop the scheduler. */ - return; - } - if (scheduler != null) { - scheduler.cancel(); + public void onInitialized(DownloadManager downloadManager) { + // Do nothing. + } + + @Override + public void onDownloadStateChanged( + DownloadManager downloadManager, DownloadState downloadState) { + if (downloadService != null) { + downloadService.notifyDownloadStateChange(downloadState); } } @Override - public void requirementsNotMet(RequirementsWatcher requirementsWatcher) { - try { - notifyService(); - } catch (Exception e) { - /* Do nothing. The service isn't running anyway. */ + public final void onIdle(DownloadManager downloadManager) { + if (downloadService != null) { + downloadService.stop(); + } + } + + @Override + public void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements) { + boolean requirementsMet = notMetRequirements == 0; + if (downloadService == null && requirementsMet) { + try { + Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); + context.startService(intent); + } catch (IllegalStateException e) { + /* startService fails if the app is in the background then don't stop the scheduler. */ + return; + } } if (scheduler != null) { + setSchedulerEnabled(/* enabled= */ !requirementsMet, requirements); + } + } + + private void setSchedulerEnabled(boolean enabled, Requirements requirements) { + if (!enabled) { + scheduler.cancel(); + } else { String servicePackage = context.getPackageName(); boolean success = scheduler.schedule(requirements, servicePackage, ACTION_RESTART); if (!success) { @@ -551,15 +518,5 @@ public abstract class DownloadService extends Service { } } } - - private void notifyService() throws Exception { - Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); - try { - context.startService(intent); - } catch (IllegalStateException e) { - /* startService will fail if the app is in the background and the service isn't running. */ - throw new Exception(e); - } - } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java new file mode 100644 index 0000000000..7bbd078822 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2018 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.offline; + +import android.net.Uri; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents state of a download. */ +public final class DownloadState { + + /** + * Download states. One of {@link #STATE_QUEUED}, {@link #STATE_STOPPED}, {@link + * #STATE_DOWNLOADING}, {@link #STATE_COMPLETED}, {@link #STATE_FAILED}, {@link #STATE_REMOVING}, + * {@link #STATE_REMOVED} or {@link #STATE_RESTARTING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_QUEUED, + STATE_STOPPED, + STATE_DOWNLOADING, + STATE_COMPLETED, + STATE_FAILED, + STATE_REMOVING, + STATE_REMOVED, + STATE_RESTARTING + }) + public @interface State {} + /** The download is waiting to be started. */ + public static final int STATE_QUEUED = 0; + /** The download is stopped. */ + public static final int STATE_STOPPED = 1; + /** The download is currently started. */ + public static final int STATE_DOWNLOADING = 2; + /** The download completed. */ + public static final int STATE_COMPLETED = 3; + /** The download failed. */ + public static final int STATE_FAILED = 4; + /** The download is being removed. */ + public static final int STATE_REMOVING = 5; + /** The download is removed. */ + public static final int STATE_REMOVED = 6; + /** The download will restart after all downloaded data is removed. */ + public static final int STATE_RESTARTING = 7; + + /** Failure reasons. Either {@link #FAILURE_REASON_NONE} or {@link #FAILURE_REASON_UNKNOWN}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FAILURE_REASON_NONE, FAILURE_REASON_UNKNOWN}) + public @interface FailureReason {} + /** The download isn't failed. */ + public static final int FAILURE_REASON_NONE = 0; + /** The download is failed because of unknown reason. */ + public static final int FAILURE_REASON_UNKNOWN = 1; + + /** + * Download stop flags. Possible flag values are {@link #STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY} and + * {@link #STOP_FLAG_STOPPED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY, STOP_FLAG_STOPPED}) + public @interface StopFlags {} + /** Download can't be started as the manager isn't ready. */ + public static final int STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY = 1; + /** All downloads are stopped by the application. */ + public static final int STOP_FLAG_STOPPED = 1 << 1; + + /** Returns the state string for the given state value. */ + public static String getStateString(@State int state) { + switch (state) { + case STATE_QUEUED: + return "QUEUED"; + case STATE_STOPPED: + return "STOPPED"; + case STATE_DOWNLOADING: + return "DOWNLOADING"; + case STATE_COMPLETED: + return "COMPLETED"; + case STATE_FAILED: + return "FAILED"; + case STATE_REMOVING: + return "REMOVING"; + case STATE_REMOVED: + return "REMOVED"; + case STATE_RESTARTING: + return "RESTARTING"; + default: + throw new IllegalStateException(); + } + } + + /** Returns the failure string for the given failure reason value. */ + public static String getFailureString(@FailureReason int failureReason) { + switch (failureReason) { + case FAILURE_REASON_NONE: + return "NO_REASON"; + case FAILURE_REASON_UNKNOWN: + return "UNKNOWN_REASON"; + default: + throw new IllegalStateException(); + } + } + + /** The unique content id. */ + public final String id; + /** The type of the content. */ + public final String type; + /** The Uri of the content. */ + public final Uri uri; + /** A custom key for cache indexing. */ + @Nullable public final String cacheKey; + /** The state of the download. */ + @State public final int state; + /** The estimated download percentage, or {@link C#PERCENTAGE_UNSET} if unavailable. */ + public final float downloadPercentage; + /** The total number of downloaded bytes. */ + public final long downloadedBytes; + /** The total size of the media, or {@link C#LENGTH_UNSET} if unknown. */ + public final long totalBytes; + /** The first time when download entry is created. */ + public final long startTimeMs; + /** The last update time. */ + public final long updateTimeMs; + /** Keys of streams to be downloaded. If empty, all streams will be downloaded. */ + public final StreamKey[] streamKeys; + /** Optional custom data. */ + public final byte[] customMetadata; + /** + * If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise {@link + * #FAILURE_REASON_NONE}. + */ + @FailureReason public final int failureReason; + /** Download stop flags. These flags stop downloading any content. */ + public final int stopFlags; + + /* package */ DownloadState( + String id, + String type, + Uri uri, + @Nullable String cacheKey, + @State int state, + float downloadPercentage, + long downloadedBytes, + long totalBytes, + @FailureReason int failureReason, + @StopFlags int stopFlags, + long startTimeMs, + long updateTimeMs, + StreamKey[] streamKeys, + byte[] customMetadata) { + this.stopFlags = stopFlags; + Assertions.checkState( + failureReason == FAILURE_REASON_NONE ? state != STATE_FAILED : state == STATE_FAILED); + // TODO enable this when we start changing state immediately + // Assertions.checkState(stopFlags == 0 || (state != STATE_DOWNLOADING && state != + // STATE_QUEUED)); + this.id = id; + this.type = type; + this.uri = uri; + this.cacheKey = cacheKey; + this.streamKeys = streamKeys; + this.customMetadata = customMetadata; + this.state = state; + this.downloadPercentage = downloadPercentage; + this.downloadedBytes = downloadedBytes; + this.totalBytes = totalBytes; + this.failureReason = failureReason; + this.startTimeMs = startTimeMs; + this.updateTimeMs = updateTimeMs; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java new file mode 100644 index 0000000000..680976c77b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2019 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.offline; + +/** Provides random read-write access to the result set returned by a database query. */ +interface DownloadStateCursor { + + /** Returns the DownloadState at the current position. */ + DownloadState getDownloadState(); + + /** Returns the numbers of DownloadStates in the cursor. */ + int getCount(); + + /** + * Returns the current position of the cursor in the DownloadState set. The value is zero-based. + * When the DownloadState set is first returned the cursor will be at positon -1, which is before + * the first DownloadState. After the last DownloadState is returned another call to next() will + * leave the cursor past the last entry, at a position of count(). + * + * @return the current cursor position. + */ + int getPosition(); + + /** + * Move the cursor to an absolute position. The valid range of values is -1 <= position <= + * count. + * + *

    This method will return true if the request destination was reachable, otherwise, it returns + * false. + * + * @param position the zero-based position to move to. + * @return whether the requested move fully succeeded. + */ + boolean moveToPosition(int position); + + /** + * Move the cursor to the first DownloadState. + * + *

    This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToFirst() { + return moveToPosition(0); + } + + /** + * Move the cursor to the last DownloadState. + * + *

    This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToLast() { + return moveToPosition(getCount() - 1); + } + + /** + * Move the cursor to the next DownloadState. + * + *

    This method will return false if the cursor is already past the last entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToNext() { + return moveToPosition(getPosition() + 1); + } + + /** + * Move the cursor to the previous DownloadState. + * + *

    This method will return false if the cursor is already before the first entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToPrevious() { + return moveToPosition(getPosition() - 1); + } + + /** Returns whether the cursor is pointing to the first DownloadState. */ + default boolean isFirst() { + return getPosition() == 0 && getCount() != 0; + } + + /** Returns whether the cursor is pointing to the last DownloadState. */ + default boolean isLast() { + int count = getCount(); + return getPosition() == (count - 1) && count != 0; + } + + /** Returns whether the cursor is pointing to the position before the first DownloadState. */ + default boolean isBeforeFirst() { + if (getCount() == 0) { + return true; + } + return getPosition() == -1; + } + + /** Returns whether the cursor is pointing to the position after the last DownloadState. */ + default boolean isAfterLast() { + if (getCount() == 0) { + return true; + } + return getPosition() == getCount(); + } + + /** Closes the Cursor, releasing all of its resources and making it completely invalid. */ + void close(); + + /** Returns whether the cursor is closed */ + boolean isClosed(); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java index 74b918c06d..59a11934b1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.FileDataSourceFactory; import com.google.android.exoplayer2.upstream.PriorityDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; @@ -108,16 +109,18 @@ public final class DownloaderConstructorHelper { cacheReadDataSourceFactory != null ? cacheReadDataSourceFactory : new FileDataSourceFactory(); - DataSink.Factory writeDataSinkFactory = - cacheWriteDataSinkFactory != null - ? cacheWriteDataSinkFactory - : new CacheDataSinkFactory(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE); + if (cacheWriteDataSinkFactory == null) { + CacheDataSinkFactory factory = + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE); + factory.experimental_setRespectCacheFragmentationFlag(true); + cacheWriteDataSinkFactory = factory; + } onlineCacheDataSourceFactory = new CacheDataSourceFactory( cache, upstreamFactory, readDataSourceFactory, - writeDataSinkFactory, + cacheWriteDataSinkFactory, CacheDataSource.FLAG_BLOCK_ON_CACHE, /* eventListener= */ null, cacheKeyFactory); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/FilteringManifestParser.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/FilteringManifestParser.java index c32cdf7126..c25e5099cf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/FilteringManifestParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/FilteringManifestParser.java @@ -16,22 +16,27 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import java.io.IOException; import java.io.InputStream; import java.util.List; -/** A manifest parser that includes only the streams identified by the given stream keys. */ +/** + * A manifest parser that includes only the streams identified by the given stream keys. + * + * @param The {@link FilterableManifest} type. + */ public final class FilteringManifestParser> implements Parser { - private final Parser parser; - private final List streamKeys; + private final Parser parser; + @Nullable private final List streamKeys; /** * @param parser A parser for the manifest that will be filtered. * @param streamKeys The stream keys. If null or empty then filtering will not occur. */ - public FilteringManifestParser(Parser parser, List streamKeys) { + public FilteringManifestParser(Parser parser, @Nullable List streamKeys) { this.parser = parser; this.streamKeys = streamKeys; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java index 70587694c4..2ec14368ca 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java @@ -17,19 +17,35 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import android.support.annotation.Nullable; +import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.source.TrackGroupArray; -import java.util.Collections; -import java.util.List; /** A {@link DownloadHelper} for progressive streams. */ public final class ProgressiveDownloadHelper extends DownloadHelper { + /** + * Creates download helper for progressive streams. + * + * @param uri The stream {@link Uri}. + */ public ProgressiveDownloadHelper(Uri uri) { - this(uri, null); + this(uri, /* cacheKey= */ null); } - public ProgressiveDownloadHelper(Uri uri, @Nullable String customCacheKey) { - super(DownloadAction.TYPE_PROGRESSIVE, uri, customCacheKey); + /** + * Creates download helper for progressive streams. + * + * @param uri The stream {@link Uri}. + * @param cacheKey An optional cache key. + */ + public ProgressiveDownloadHelper(Uri uri, @Nullable String cacheKey) { + super( + DownloadAction.TYPE_PROGRESSIVE, + uri, + cacheKey, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, + (handler, videoListener, audioListener, metadata, text, drm) -> new Renderer[0], + /* drmSessionManager= */ null); } @Override @@ -43,7 +59,8 @@ public final class ProgressiveDownloadHelper extends DownloadHelper { } @Override - protected List toStreamKeys(List trackKeys) { - return Collections.emptyList(); + protected StreamKey toStreamKey( + int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { + return new StreamKey(periodIndex, trackGroupIndex, trackIndexInTrackGroup); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 41f0944b75..25b4e07bcd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -53,7 +53,11 @@ public final class ProgressiveDownloader implements Downloader { Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) { this.dataSpec = new DataSpec( - uri, /* absoluteStreamPosition= */ 0, C.LENGTH_UNSET, customCacheKey, /* flags= */ 0); + uri, + /* absoluteStreamPosition= */ 0, + C.LENGTH_UNSET, + customCacheKey, + /* flags= */ DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); this.cache = constructorHelper.getCache(); this.dataSource = constructorHelper.createCacheDataSource(); this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/StreamKey.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/StreamKey.java index 838073cd99..1caeaca61e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/StreamKey.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/StreamKey.java @@ -19,8 +19,11 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; /** - * Identifies a given track by the index of the containing period, the index of the containing group - * within the period, and the index of the track within the group. + * A key for a subset of media which can be separately loaded (a "stream"). + * + *

    The stream key consists of a period index, a group index within the period and a track index + * within the group. The interpretation of these indices depends on the type of media for which the + * stream key is used. */ public final class StreamKey implements Comparable { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/TrackKey.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/TrackKey.java deleted file mode 100644 index f6a411c3a1..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/TrackKey.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2018 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.offline; - -/** - * Identifies a given track by the index of the containing period, the index of the containing group - * within the period, and the index of the track within the group. - */ -public final class TrackKey { - - /** The period index. */ - public final int periodIndex; - /** The group index. */ - public final int groupIndex; - /** The track index. */ - public final int trackIndex; - - /** - * @param periodIndex The period index. - * @param groupIndex The group index. - * @param trackIndex The track index. - */ - public TrackKey(int periodIndex, int groupIndex, int trackIndex) { - this.periodIndex = periodIndex; - this.groupIndex = groupIndex; - this.trackIndex = trackIndex; - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java index ed06d3745a..b8272dc036 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -130,7 +130,7 @@ public final class PlatformScheduler implements Scheduler { PersistableBundle extras = new PersistableBundle(); extras.putString(KEY_SERVICE_ACTION, serviceAction); extras.putString(KEY_SERVICE_PACKAGE, servicePackage); - extras.putInt(KEY_REQUIREMENTS, requirements.getRequirementsData()); + extras.putInt(KEY_REQUIREMENTS, requirements.getRequirements()); builder.setExtras(extras); return builder.build(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 5acd31ee0d..77630a4543 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -25,6 +25,7 @@ import android.net.NetworkInfo; import android.os.BatteryManager; import android.os.PowerManager; import android.support.annotation.IntDef; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; @@ -50,22 +51,49 @@ public final class Requirements { NETWORK_TYPE_METERED, }) public @interface NetworkType {} + + /** + * Requirement flags. + * + *

    Combination of the following values is possible: + * + *

      + *
    • Only one of {@link #NETWORK_TYPE_ANY}, {@link #NETWORK_TYPE_UNMETERED}, {@link + * #NETWORK_TYPE_NOT_ROAMING} or {@link #NETWORK_TYPE_METERED}. + *
    • {@link #DEVICE_IDLE} + *
    • {@link #DEVICE_CHARGING} + *
        + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + NETWORK_TYPE_ANY, + NETWORK_TYPE_UNMETERED, + NETWORK_TYPE_NOT_ROAMING, + NETWORK_TYPE_METERED, + DEVICE_IDLE, + DEVICE_CHARGING + }) + public @interface RequirementFlags {} + /** This job doesn't require network connectivity. */ public static final int NETWORK_TYPE_NONE = 0; /** This job requires network connectivity. */ public static final int NETWORK_TYPE_ANY = 1; /** This job requires network connectivity that is unmetered. */ - public static final int NETWORK_TYPE_UNMETERED = 2; + public static final int NETWORK_TYPE_UNMETERED = 1 << 1; /** This job requires network connectivity that is not roaming. */ - public static final int NETWORK_TYPE_NOT_ROAMING = 3; + public static final int NETWORK_TYPE_NOT_ROAMING = 1 << 2; /** This job requires metered connectivity such as most cellular data networks. */ - public static final int NETWORK_TYPE_METERED = 4; + public static final int NETWORK_TYPE_METERED = 1 << 3; /** This job requires the device to be idle. */ - private static final int DEVICE_IDLE = 8; + public static final int DEVICE_IDLE = 1 << 4; /** This job requires the device to be charging. */ - private static final int DEVICE_CHARGING = 16; + public static final int DEVICE_CHARGING = 1 << 5; - private static final int NETWORK_TYPE_MASK = 7; + private static final int NETWORK_TYPE_MASK = 0b1111; private static final String TAG = "Requirements"; @@ -86,7 +114,7 @@ public final class Requirements { } } - private final int requirements; + @RequirementFlags private final int requirements; /** * @param networkType Required network type. @@ -97,9 +125,12 @@ public final class Requirements { this(networkType | (charging ? DEVICE_CHARGING : 0) | (idle ? DEVICE_IDLE : 0)); } - /** @param requirementsData The value returned by {@link #getRequirementsData()}. */ - public Requirements(int requirementsData) { - this.requirements = requirementsData; + /** @param requirements A combination of requirement flags. */ + public Requirements(@RequirementFlags int requirements) { + this.requirements = requirements; + int networkType = getRequiredNetworkType(); + // Check if only one network type is specified. + Assertions.checkState((networkType & (networkType - 1)) == 0); } /** Returns required network type. */ @@ -121,15 +152,28 @@ public final class Requirements { * Returns whether the requirements are met. * * @param context Any context. + * @return Whether the requirements are met. */ public boolean checkRequirements(Context context) { - return checkNetworkRequirements(context) - && checkChargingRequirement(context) - && checkIdleRequirement(context); + return getNotMetRequirements(context) == 0; } - /** Returns the encoded requirements data which can be used with {@link #Requirements(int)}. */ - public int getRequirementsData() { + /** + * Returns {@link RequirementFlags} that are not met, or 0. + * + * @param context Any context. + * @return RequirementFlags that are not met, or 0. + */ + @RequirementFlags + public int getNotMetRequirements(Context context) { + return (!checkNetworkRequirements(context) ? getRequiredNetworkType() : 0) + | (!checkChargingRequirement(context) ? DEVICE_CHARGING : 0) + | (!checkIdleRequirement(context) ? DEVICE_IDLE : 0); + } + + /** Returns the requirement flags. */ + @RequirementFlags + public int getRequirements() { return requirements; } @@ -239,4 +283,20 @@ public final class Requirements { + (isIdleRequired() ? ",idle" : "") + '}'; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return requirements == ((Requirements) o).requirements; + } + + @Override + public int hashCode() { + return requirements; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index d1eb28cc2a..686f19d161 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -44,18 +44,23 @@ public final class RequirementsWatcher { public interface Listener { /** - * Called when the requirements are met. + * Called when all of the requirements are met. * * @param requirementsWatcher Calling instance. */ void requirementsMet(RequirementsWatcher requirementsWatcher); /** - * Called when the requirements are not met. + * Called when there is at least one not met requirement and there is a change on which of the + * requirements are not met. * * @param requirementsWatcher Calling instance. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. */ - void requirementsNotMet(RequirementsWatcher requirementsWatcher); + void requirementsNotMet( + RequirementsWatcher requirementsWatcher, + @Requirements.RequirementFlags int notMetRequirements); } private static final String TAG = "RequirementsWatcher"; @@ -65,8 +70,9 @@ public final class RequirementsWatcher { private final Requirements requirements; private DeviceStatusChangeReceiver receiver; - private boolean requirementsWereMet; + @Requirements.RequirementFlags private int notMetRequirements; private CapabilityValidatedCallback networkCallback; + private Handler handler; /** * @param context Any context. @@ -83,11 +89,15 @@ public final class RequirementsWatcher { /** * Starts watching for changes. Must be called from a thread that has an associated {@link * Looper}. Listener methods are called on the caller thread. + * + * @return Initial {@link Requirements.RequirementFlags RequirementFlags} that are not met, or 0. */ - public void start() { + @Requirements.RequirementFlags + public int start() { Assertions.checkNotNull(Looper.myLooper()); + handler = new Handler(); - requirementsWereMet = requirements.checkRequirements(context); + notMetRequirements = requirements.getNotMetRequirements(context); IntentFilter filter = new IntentFilter(); if (requirements.getRequiredNetworkType() != Requirements.NETWORK_TYPE_NONE) { @@ -110,8 +120,9 @@ public final class RequirementsWatcher { } } receiver = new DeviceStatusChangeReceiver(); - context.registerReceiver(receiver, filter, null, new Handler()); + context.registerReceiver(receiver, filter, null, handler); logd(this + " started"); + return notMetRequirements; } /** Stops watching for changes. */ @@ -159,18 +170,19 @@ public final class RequirementsWatcher { } private void checkRequirements() { - boolean requirementsAreMet = requirements.checkRequirements(context); - if (requirementsAreMet == requirementsWereMet) { - logd("requirementsAreMet is still " + requirementsAreMet); + @Requirements.RequirementFlags + int notMetRequirements = requirements.getNotMetRequirements(context); + if (this.notMetRequirements == notMetRequirements) { + logd("notMetRequirements hasn't changed: " + notMetRequirements); return; } - requirementsWereMet = requirementsAreMet; - if (requirementsAreMet) { + this.notMetRequirements = notMetRequirements; + if (notMetRequirements == 0) { logd("start job"); listener.requirementsMet(this); } else { logd("stop job"); - listener.requirementsNotMet(this); + listener.requirementsNotMet(this, notMetRequirements); } } @@ -194,16 +206,22 @@ public final class RequirementsWatcher { private final class CapabilityValidatedCallback extends ConnectivityManager.NetworkCallback { @Override public void onAvailable(Network network) { - super.onAvailable(network); - logd(RequirementsWatcher.this + " NetworkCallback.onAvailable"); - checkRequirements(); + onNetworkCallback(); } @Override public void onLost(Network network) { - super.onLost(network); - logd(RequirementsWatcher.this + " NetworkCallback.onLost"); - checkRequirements(); + onNetworkCallback(); + } + + private void onNetworkCallback() { + handler.post( + () -> { + if (networkCallback != null) { + logd(RequirementsWatcher.this + " NetworkCallback"); + checkRequirements(); + } + }); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java index 3d6e204c9c..189467b47e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java @@ -16,8 +16,8 @@ package com.google.android.exoplayer2.source; import android.os.Handler; +import android.os.Looper; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -35,9 +35,9 @@ public abstract class BaseMediaSource implements MediaSource { private final ArrayList sourceInfoListeners; private final MediaSourceEventListener.EventDispatcher eventDispatcher; - private @Nullable ExoPlayer player; - private @Nullable Timeline timeline; - private @Nullable Object manifest; + @Nullable private Looper looper; + @Nullable private Timeline timeline; + @Nullable private Object manifest; public BaseMediaSource() { sourceInfoListeners = new ArrayList<>(/* initialCapacity= */ 1); @@ -48,21 +48,16 @@ public abstract class BaseMediaSource implements MediaSource { * Starts source preparation. This method is called at most once until the next call to {@link * #releaseSourceInternal()}. * - * @param player The player for which this source is being prepared. - * @param isTopLevelSource Whether this source has been passed directly to {@link - * ExoPlayer#prepare(MediaSource)} or {@link ExoPlayer#prepare(MediaSource, boolean, - * boolean)}. * @param mediaTransferListener The transfer listener which should be informed of any media data * transfers. May be null if no listener is available. Note that this listener should usually * be only informed of transfers related to the media loads and not of auxiliary loads for * manifests and other data. */ - protected abstract void prepareSourceInternal( - ExoPlayer player, boolean isTopLevelSource, @Nullable TransferListener mediaTransferListener); + protected abstract void prepareSourceInternal(@Nullable TransferListener mediaTransferListener); /** * Releases the source. This method is called exactly once after each call to {@link - * #prepareSourceInternal(ExoPlayer, boolean, TransferListener)}. + * #prepareSourceInternal(TransferListener)}. */ protected abstract void releaseSourceInternal(); @@ -135,15 +130,14 @@ public abstract class BaseMediaSource implements MediaSource { @Override public final void prepareSource( - ExoPlayer player, - boolean isTopLevelSource, SourceInfoRefreshListener listener, @Nullable TransferListener mediaTransferListener) { - Assertions.checkArgument(this.player == null || this.player == player); + Looper looper = Looper.myLooper(); + Assertions.checkArgument(this.looper == null || this.looper == looper); sourceInfoListeners.add(listener); - if (this.player == null) { - this.player = player; - prepareSourceInternal(player, isTopLevelSource, mediaTransferListener); + if (this.looper == null) { + this.looper = looper; + prepareSourceInternal(mediaTransferListener); } else if (timeline != null) { listener.onSourceInfoRefreshed(/* source= */ this, timeline, manifest); } @@ -153,7 +147,7 @@ public abstract class BaseMediaSource implements MediaSource { public final void releaseSource(SourceInfoRefreshListener listener) { sourceInfoListeners.remove(listener); if (sourceInfoListeners.isEmpty()) { - player = null; + looper = null; timeline = null; manifest = null; releaseSourceInternal(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java index 1dbb41dfb0..d3b8226822 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.source; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -193,11 +192,8 @@ public final class ClippingMediaSource extends CompositeMediaSource { } @Override - public void prepareSourceInternal( - ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - super.prepareSourceInternal(player, isTopLevelSource, mediaTransferListener); + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); prepareChildSource(/* id= */ null, mediaSource); } @@ -210,10 +206,10 @@ public final class ClippingMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { ClippingMediaPeriod mediaPeriod = new ClippingMediaPeriod( - mediaSource.createPeriod(id, allocator), + mediaSource.createPeriod(id, allocator, startPositionUs), enableInitialDiscontinuity, periodStartUs, periodEndUs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index 69fa4b094b..dbf5812f98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.source; import android.os.Handler; import android.support.annotation.CallSuper; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -35,7 +34,6 @@ public abstract class CompositeMediaSource extends BaseMediaSource { private final HashMap childSources; - private @Nullable ExoPlayer player; private @Nullable Handler eventHandler; private @Nullable TransferListener mediaTransferListener; @@ -46,11 +44,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @Override @CallSuper - public void prepareSourceInternal( - ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - this.player = player; + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; eventHandler = new Handler(); } @@ -71,7 +65,6 @@ public abstract class CompositeMediaSource extends BaseMediaSource { childSource.mediaSource.removeEventListener(childSource.eventListener); } childSources.clear(); - player = null; } /** @@ -105,11 +98,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { MediaSourceEventListener eventListener = new ForwardingEventListener(id); childSources.put(id, new MediaSourceAndListener(mediaSource, sourceListener, eventListener)); mediaSource.addEventListener(Assertions.checkNotNull(eventHandler), eventListener); - mediaSource.prepareSource( - Assertions.checkNotNull(player), - /* isTopLevelSource= */ false, - sourceListener, - mediaTransferListener); + mediaSource.prepareSource(sourceListener, mediaTransferListener); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 26667e641f..6dc7a0a327 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -16,19 +16,19 @@ package com.google.android.exoplayer2.source; import android.os.Handler; +import android.os.Message; +import android.support.annotation.GuardedBy; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Pair; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.PlayerMessage; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.EventDispatcher; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -45,8 +45,7 @@ import java.util.Map; * during playback. It is valid for the same {@link MediaSource} instance to be present more than * once in the concatenation. Access to this class is thread-safe. */ -public class ConcatenatingMediaSource extends CompositeMediaSource - implements PlayerMessage.Target { +public class ConcatenatingMediaSource extends CompositeMediaSource { private static final int MSG_ADD = 0; private static final int MSG_REMOVE = 1; @@ -55,22 +54,21 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSourcesPublic; + @Nullable private Handler playbackThreadHandler; - // Accessed on the playback thread. + // Accessed on the playback thread only. private final List mediaSourceHolders; private final Map mediaSourceByMediaPeriod; private final Map mediaSourceByUid; - private final List pendingOnCompletionActions; private final boolean isAtomic; private final boolean useLazyPreparation; private final Timeline.Window window; private final Timeline.Period period; - private @Nullable ExoPlayer player; - private @Nullable Handler playerApplicationHandler; private boolean listenerNotificationScheduled; + private EventDispatcher pendingOnCompletionActions; private ShuffleOrder shuffleOrder; private int windowCount; private int periodCount; @@ -129,7 +127,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(); this.mediaSourcesPublic = new ArrayList<>(); this.mediaSourceHolders = new ArrayList<>(); - this.pendingOnCompletionActions = new ArrayList<>(); + this.pendingOnCompletionActions = new EventDispatcher<>(); this.isAtomic = isAtomic; this.useLazyPreparation = useLazyPreparation; window = new Timeline.Window(); @@ -143,19 +141,20 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources) { - addMediaSources(mediaSourcesPublic.size(), mediaSources, null); + addPublicMediaSources( + mediaSourcesPublic.size(), + mediaSources, + /* handler= */ null, + /* actionOnCompletion= */ null); } /** @@ -199,12 +208,13 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, @Nullable Runnable actionOnCompletion) { - addMediaSources(mediaSourcesPublic.size(), mediaSources, actionOnCompletion); + Collection mediaSources, Handler handler, Runnable actionOnCompletion) { + addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, actionOnCompletion); } /** @@ -216,7 +226,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources) { - addMediaSources(index, mediaSources, null); + addPublicMediaSources(index, mediaSources, /* handler= */ null, /* actionOnCompletion= */ null); } /** @@ -226,28 +236,16 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, @Nullable Runnable actionOnCompletion) { - for (MediaSource mediaSource : mediaSources) { - Assertions.checkNotNull(mediaSource); - } - List mediaSourceHolders = new ArrayList<>(mediaSources.size()); - for (MediaSource mediaSource : mediaSources) { - mediaSourceHolders.add(new MediaSourceHolder(mediaSource)); - } - mediaSourcesPublic.addAll(index, mediaSourceHolders); - if (player != null && !mediaSources.isEmpty()) { - player - .createMessage(this) - .setType(MSG_ADD) - .setPayload(new MessageData<>(index, mediaSourceHolders, actionOnCompletion)) - .send(); - } else if (actionOnCompletion != null) { - actionOnCompletion.run(); - } + int index, + Collection mediaSources, + Handler handler, + Runnable actionOnCompletion) { + addPublicMediaSources(index, mediaSources, handler, actionOnCompletion); } /** @@ -263,26 +261,27 @@ public class ConcatenatingMediaSource extends CompositeMediaSourceNote: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int, - * int, Runnable)} instead. + * int, Handler, Runnable)} instead. * *

        Note: If you want to remove a set of contiguous sources, it's preferable to use {@link - * #removeMediaSourceRange(int, int, Runnable)} instead. + * #removeMediaSourceRange(int, int, Handler, Runnable)} instead. * * @param index The index at which the media source will be removed. This index must be in the * range of 0 <= index < {@link #getSize()}. + * @param handler The {@link Handler} to run {@code actionOnCompletion}. * @param actionOnCompletion A {@link Runnable} which is executed immediately after the media * source has been removed from the playlist. */ public final synchronized void removeMediaSource( - int index, @Nullable Runnable actionOnCompletion) { - removeMediaSourceRange(index, index + 1, actionOnCompletion); + int index, Handler handler, Runnable actionOnCompletion) { + removePublicMediaSources(index, index + 1, handler, actionOnCompletion); } /** @@ -300,7 +299,8 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(fromIndex, toIndex, actionOnCompletion)) - .send(); - } else if (actionOnCompletion != null) { - actionOnCompletion.run(); - } + int fromIndex, int toIndex, Handler handler, Runnable actionOnCompletion) { + removePublicMediaSources(fromIndex, toIndex, handler, actionOnCompletion); } /** @@ -348,7 +334,8 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(currentIndex, newIndex, actionOnCompletion)) - .send(); - } else if (actionOnCompletion != null) { - actionOnCompletion.run(); - } + int currentIndex, int newIndex, Handler handler, Runnable actionOnCompletion) { + movePublicMediaSource(currentIndex, newIndex, handler, actionOnCompletion); } /** Clears the playlist. */ public final synchronized void clear() { - clear(/* actionOnCompletion= */ null); + removeMediaSourceRange(0, getSize()); } /** * Clears the playlist and executes a custom action on completion. * + * @param handler The {@link Handler} to run {@code actionOnCompletion}. * @param actionOnCompletion A {@link Runnable} which is executed immediately after the playlist * has been cleared. */ - public final synchronized void clear(@Nullable Runnable actionOnCompletion) { - removeMediaSourceRange(0, getSize(), actionOnCompletion); + public final synchronized void clear(Handler handler, Runnable actionOnCompletion) { + removeMediaSourceRange(0, getSize(), handler, actionOnCompletion); } /** Returns the number of media sources in the playlist. */ @@ -418,41 +392,24 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(/* index= */ 0, shuffleOrder, actionOnCompletion)) - .send(); - } else { - this.shuffleOrder = - shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; - if (actionOnCompletion != null) { - actionOnCompletion.run(); - } - } + ShuffleOrder shuffleOrder, Handler handler, Runnable actionOnCompletion) { + setPublicShuffleOrder(shuffleOrder, handler, actionOnCompletion); } + // CompositeMediaSource implementation. + @Override @Nullable public Object getTag() { @@ -461,18 +418,15 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, + @Nullable Handler handler, + @Nullable Runnable actionOnCompletion) { + Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); } - switch (messageType) { + List mediaSourceHolders = new ArrayList<>(mediaSources.size()); + for (MediaSource mediaSource : mediaSources) { + mediaSourceHolders.add(new MediaSourceHolder(mediaSource)); + } + mediaSourcesPublic.addAll(index, mediaSourceHolders); + if (playbackThreadHandler != null && !mediaSources.isEmpty()) { + playbackThreadHandler + .obtainMessage( + MSG_ADD, new MessageData<>(index, mediaSourceHolders, handler, actionOnCompletion)) + .sendToTarget(); + } else if (actionOnCompletion != null && handler != null) { + handler.post(actionOnCompletion); + } + } + + @GuardedBy("this") + private void removePublicMediaSources( + int fromIndex, + int toIndex, + @Nullable Handler handler, + @Nullable Runnable actionOnCompletion) { + Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + Util.removeRange(mediaSourcesPublic, fromIndex, toIndex); + if (playbackThreadHandler != null) { + playbackThreadHandler + .obtainMessage( + MSG_REMOVE, new MessageData<>(fromIndex, toIndex, handler, actionOnCompletion)) + .sendToTarget(); + } else if (actionOnCompletion != null && handler != null) { + handler.post(actionOnCompletion); + } + } + + @GuardedBy("this") + private void movePublicMediaSource( + int currentIndex, + int newIndex, + @Nullable Handler handler, + @Nullable Runnable actionOnCompletion) { + Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + mediaSourcesPublic.add(newIndex, mediaSourcesPublic.remove(currentIndex)); + if (playbackThreadHandler != null) { + playbackThreadHandler + .obtainMessage( + MSG_MOVE, new MessageData<>(currentIndex, newIndex, handler, actionOnCompletion)) + .sendToTarget(); + } else if (actionOnCompletion != null && handler != null) { + handler.post(actionOnCompletion); + } + } + + @GuardedBy("this") + private void setPublicShuffleOrder( + ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable actionOnCompletion) { + Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + if (playbackThreadHandler != null) { + int size = getSize(); + if (shuffleOrder.getLength() != size) { + shuffleOrder = + shuffleOrder + .cloneAndClear() + .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size); + } + playbackThreadHandler + .obtainMessage( + MSG_SET_SHUFFLE_ORDER, + new MessageData<>(/* index= */ 0, shuffleOrder, handler, actionOnCompletion)) + .sendToTarget(); + } else { + this.shuffleOrder = + shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; + if (actionOnCompletion != null && handler != null) { + handler.post(actionOnCompletion); + } + } + } + + // Internal methods. Called on the playback thread. + + @SuppressWarnings("unchecked") + private boolean handleMessage(Message msg) { + switch (msg.what) { case MSG_ADD: MessageData> addMessage = - (MessageData>) Util.castNonNull(message); + (MessageData>) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrder.cloneAndInsert(addMessage.index, addMessage.customData.size()); addMediaSourcesInternal(addMessage.index, addMessage.customData); - scheduleListenerNotification(addMessage.actionOnCompletion); + scheduleListenerNotification(addMessage.handler, addMessage.actionOnCompletion); break; case MSG_REMOVE: - MessageData removeMessage = (MessageData) Util.castNonNull(message); + MessageData removeMessage = (MessageData) Util.castNonNull(msg.obj); int fromIndex = removeMessage.index; int toIndex = removeMessage.customData; if (fromIndex == 0 && toIndex == shuffleOrder.getLength()) { @@ -584,64 +628,64 @@ public class ConcatenatingMediaSource extends CompositeMediaSource= fromIndex; index--) { removeMediaSourceInternal(index); } - scheduleListenerNotification(removeMessage.actionOnCompletion); + scheduleListenerNotification(removeMessage.handler, removeMessage.actionOnCompletion); break; case MSG_MOVE: - MessageData moveMessage = (MessageData) Util.castNonNull(message); + MessageData moveMessage = (MessageData) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrder.cloneAndRemove(moveMessage.index, moveMessage.index + 1); shuffleOrder = shuffleOrder.cloneAndInsert(moveMessage.customData, 1); moveMediaSourceInternal(moveMessage.index, moveMessage.customData); - scheduleListenerNotification(moveMessage.actionOnCompletion); + scheduleListenerNotification(moveMessage.handler, moveMessage.actionOnCompletion); break; case MSG_SET_SHUFFLE_ORDER: MessageData shuffleOrderMessage = - (MessageData) Util.castNonNull(message); + (MessageData) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrderMessage.customData; - scheduleListenerNotification(shuffleOrderMessage.actionOnCompletion); + scheduleListenerNotification( + shuffleOrderMessage.handler, shuffleOrderMessage.actionOnCompletion); break; case MSG_NOTIFY_LISTENER: notifyListener(); break; case MSG_ON_COMPLETION: - List actionsOnCompletion = (List) Util.castNonNull(message); - Handler handler = Assertions.checkNotNull(playerApplicationHandler); - for (int i = 0; i < actionsOnCompletion.size(); i++) { - handler.post(actionsOnCompletion.get(i)); - } + EventDispatcher actionsOnCompletion = + (EventDispatcher) Util.castNonNull(msg.obj); + actionsOnCompletion.dispatch(Runnable::run); break; default: throw new IllegalStateException(); } + return true; } - private void scheduleListenerNotification(@Nullable Runnable actionOnCompletion) { + private void scheduleListenerNotification() { + scheduleListenerNotification(/* handler= */ null, /* actionOnCompletion= */ null); + } + + private void scheduleListenerNotification( + @Nullable Handler handler, @Nullable Runnable actionOnCompletion) { if (!listenerNotificationScheduled) { - Assertions.checkNotNull(player).createMessage(this).setType(MSG_NOTIFY_LISTENER).send(); + Assertions.checkNotNull(playbackThreadHandler) + .obtainMessage(MSG_NOTIFY_LISTENER) + .sendToTarget(); listenerNotificationScheduled = true; } - if (actionOnCompletion != null) { - pendingOnCompletionActions.add(actionOnCompletion); + if (actionOnCompletion != null && handler != null) { + pendingOnCompletionActions.addListener(handler, actionOnCompletion); } } private void notifyListener() { listenerNotificationScheduled = false; - List actionsOnCompletion = - pendingOnCompletionActions.isEmpty() - ? Collections.emptyList() - : new ArrayList<>(pendingOnCompletionActions); - pendingOnCompletionActions.clear(); + EventDispatcher actionsOnCompletion = pendingOnCompletionActions; + pendingOnCompletionActions = new EventDispatcher<>(); refreshSourceInfo( new ConcatenatedTimeline( mediaSourceHolders, windowCount, periodCount, shuffleOrder, isAtomic), /* manifest= */ null); - if (!actionsOnCompletion.isEmpty()) { - Assertions.checkNotNull(player) - .createMessage(this) - .setType(MSG_ON_COMPLETION) - .setPayload(actionsOnCompletion) - .send(); - } + Assertions.checkNotNull(playbackThreadHandler) + .obtainMessage(MSG_ON_COMPLETION, actionsOnCompletion) + .sendToTarget(); } private void addMediaSourcesInternal( @@ -718,6 +762,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(); this.uid = new Object(); } @@ -852,12 +897,15 @@ public class ConcatenatingMediaSource extends CompositeMediaSourceNote: {@link #open(DataSpec)} and {@link #close()} are not supported. This implementation is + * intended to wrap upstream {@link DataSource} instances that are opened and closed directly. + */ +/* package */ final class IcyDataSource implements DataSource { + + public interface Listener { + + /** + * Called when ICY stream metadata has been split from the stream. + * + * @param metadata The stream metadata in binary form. + */ + void onIcyMetadata(ParsableByteArray metadata); + } + + private final DataSource upstream; + private final int metadataIntervalBytes; + private final Listener listener; + private final byte[] metadataLengthByteHolder; + private int bytesUntilMetadata; + + /** + * @param upstream The upstream {@link DataSource}. + * @param metadataIntervalBytes The interval between ICY stream metadata, in bytes. + * @param listener A listener to which stream metadata is delivered. + */ + public IcyDataSource(DataSource upstream, int metadataIntervalBytes, Listener listener) { + Assertions.checkArgument(metadataIntervalBytes > 0); + this.upstream = upstream; + this.metadataIntervalBytes = metadataIntervalBytes; + this.listener = listener; + metadataLengthByteHolder = new byte[1]; + bytesUntilMetadata = metadataIntervalBytes; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + if (bytesUntilMetadata == 0) { + if (readMetadata()) { + bytesUntilMetadata = metadataIntervalBytes; + } else { + return C.RESULT_END_OF_INPUT; + } + } + int bytesRead = upstream.read(buffer, offset, Math.min(bytesUntilMetadata, readLength)); + if (bytesRead != C.RESULT_END_OF_INPUT) { + bytesUntilMetadata -= bytesRead; + } + return bytesRead; + } + + @Nullable + @Override + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + throw new UnsupportedOperationException(); + } + + /** + * Reads an ICY stream metadata block, passing it to {@link #listener} unless the block is empty. + * + * @return True if the block was extracted, including if it's length byte indicated a length of + * zero. False if the end of the stream was reached. + * @throws IOException If an error occurs reading from the wrapped {@link DataSource}. + */ + private boolean readMetadata() throws IOException { + int bytesRead = upstream.read(metadataLengthByteHolder, 0, 1); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + int metadataLength = (metadataLengthByteHolder[0] & 0xFF) << 4; + if (metadataLength == 0) { + return true; + } + + int offset = 0; + int lengthRemaining = metadataLength; + byte[] metadata = new byte[metadataLength]; + while (lengthRemaining > 0) { + bytesRead = upstream.read(metadata, offset, lengthRemaining); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + offset += bytesRead; + lengthRemaining -= bytesRead; + } + + // Discard trailing zero bytes. + while (metadataLength > 0 && metadata[metadataLength - 1] == 0) { + metadataLength--; + } + + if (metadataLength > 0) { + listener.onIcyMetadata(new ParsableByteArray(metadata, metadataLength)); + } + return true; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index cac15d5ed9..0cd87561e7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -71,23 +71,21 @@ public final class LoopingMediaSource extends CompositeMediaSource { } @Override - public void prepareSourceInternal( - ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - super.prepareSourceInternal(player, isTopLevelSource, mediaTransferListener); + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); prepareChildSource(/* id= */ null, childSource); } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { if (loopCount == Integer.MAX_VALUE) { - return childSource.createPeriod(id, allocator); + return childSource.createPeriod(id, allocator, startPositionUs); } Object childPeriodUid = LoopingTimeline.getChildPeriodUidFromConcatenatedUid(id.periodUid); MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(childPeriodUid); childMediaPeriodIdToMediaPeriodId.put(childMediaPeriodId, id); - MediaPeriod mediaPeriod = childSource.createPeriod(childMediaPeriodId, allocator); + MediaPeriod mediaPeriod = + childSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); mediaPeriodToChildMediaPeriodId.put(mediaPeriod, childMediaPeriodId); return mediaPeriod; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index b7f5a07b60..532131ba7d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -19,8 +19,11 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.trackselection.TrackSelection; import java.io.IOException; +import java.util.Collections; +import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; /** @@ -83,6 +86,22 @@ public interface MediaPeriod extends SequenceableLoader { */ TrackGroupArray getTrackGroups(); + /** + * Returns a list of {@link StreamKey stream keys} which allow to filter the media in this period + * to load only the parts needed to play the provided {@link TrackSelection}. + * + *

        This method is only called after the period has been prepared. + * + * @param trackSelection The {@link TrackSelection} describing the tracks for which stream keys + * are requested. + * @return The corresponding {@link StreamKey stream keys} for the selected tracks, or an empty + * list if filtering is not possible and the entire media needs to be loaded to play the + * selected tracks. + */ + default List getStreamKeys(TrackSelection trackSelection) { + return Collections.emptyList(); + } + /** * Performs a track selection. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 21f4f20922..1419f9a98f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -18,30 +18,30 @@ package com.google.android.exoplayer2.source; import android.os.Handler; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; import java.io.IOException; /** - * Defines and provides media to be played by an {@link ExoPlayer}. A MediaSource has two main - * responsibilities: + * Defines and provides media to be played by an {@link com.google.android.exoplayer2.ExoPlayer}. A + * MediaSource has two main responsibilities: * *

          *
        • To provide the player with a {@link Timeline} defining the structure of its media, and to * provide a new timeline whenever the structure of the media changes. The MediaSource * provides these timelines by calling {@link SourceInfoRefreshListener#onSourceInfoRefreshed} - * on the {@link SourceInfoRefreshListener}s passed to {@link #prepareSource(ExoPlayer, - * boolean, SourceInfoRefreshListener, TransferListener)}. + * on the {@link SourceInfoRefreshListener}s passed to {@link + * #prepareSource(SourceInfoRefreshListener, TransferListener)}. *
        • To provide {@link MediaPeriod} instances for the periods in its timeline. MediaPeriods are - * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator)}, and provide a way for - * the player to load and read the media. + * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator, long)}, and provide a + * way for the player to load and read the media. *
        * * All methods are called on the player's internal playback thread, as described in the {@link - * ExoPlayer} Javadoc. They should not be called directly from application code. Instances can be - * re-used, but only for one {@link ExoPlayer} instance simultaneously. + * com.google.android.exoplayer2.ExoPlayer} Javadoc. They should not be called directly from + * application code. Instances can be re-used, but only for one {@link + * com.google.android.exoplayer2.ExoPlayer} instance simultaneously. */ public interface MediaSource { @@ -236,11 +236,6 @@ public interface MediaSource { *

        For each call to this method, a call to {@link #releaseSource(SourceInfoRefreshListener)} is * needed to remove the listener and to release the source if no longer required. * - * @param player The player for which this source is being prepared. - * @param isTopLevelSource Whether this source has been passed directly to {@link - * ExoPlayer#prepare(MediaSource)} or {@link ExoPlayer#prepare(MediaSource, boolean, - * boolean)}. If {@code false}, this source is being prepared by another source (e.g. {@link - * ConcatenatingMediaSource}) for composition. * @param listener The listener to be added. * @param mediaTransferListener The transfer listener which should be informed of any media data * transfers. May be null if no listener is available. Note that this listener should be only @@ -248,8 +243,6 @@ public interface MediaSource { * and other data. */ void prepareSource( - ExoPlayer player, - boolean isTopLevelSource, SourceInfoRefreshListener listener, @Nullable TransferListener mediaTransferListener); @@ -268,9 +261,10 @@ public interface MediaSource { * * @param id The identifier of the period. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. * @return A new {@link MediaPeriod}. */ - MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator); + MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs); /** * Releases the period. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java index 573e97cb13..1ea3404e81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.source; import android.support.annotation.IntDef; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -105,11 +104,8 @@ public final class MergingMediaSource extends CompositeMediaSource { } @Override - public void prepareSourceInternal( - ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - super.prepareSourceInternal(player, isTopLevelSource, mediaTransferListener); + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); for (int i = 0; i < mediaSources.length; i++) { prepareChildSource(i, mediaSources[i]); } @@ -124,13 +120,13 @@ public final class MergingMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { MediaPeriod[] periods = new MediaPeriod[mediaSources.length]; int periodIndex = timelines[0].getIndexOfPeriod(id.periodUid); for (int i = 0; i < periods.length; i++) { MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); - periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator); + periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator, startPositionUs); } return new MergingMediaPeriod(compositeSequenceableLoaderFactory, periods); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java index e5b950cf2e..ab5c5e57d9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java @@ -57,6 +57,7 @@ import com.google.android.exoplayer2.util.Util; private long largestDiscardedTimestampUs; private long largestQueuedTimestampUs; + private boolean isLastSampleQueued; private boolean upstreamKeyframeRequired; private boolean upstreamFormatRequired; private Format upstreamFormat; @@ -93,6 +94,7 @@ import com.google.android.exoplayer2.util.Util; upstreamKeyframeRequired = true; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; + isLastSampleQueued = false; if (resetUpstreamFormat) { upstreamFormat = null; upstreamFormatRequired = true; @@ -118,6 +120,7 @@ import com.google.android.exoplayer2.util.Util; Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); length -= discardCount; largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + isLastSampleQueued = discardCount == 0 && isLastSampleQueued; if (length == 0) { return 0; } else { @@ -186,6 +189,19 @@ import com.google.android.exoplayer2.util.Util; return largestQueuedTimestampUs; } + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + * + *

        Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + */ + public synchronized boolean isLastSampleQueued() { + return isLastSampleQueued; + } + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ public synchronized long getFirstTimestampUs() { return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; @@ -224,7 +240,7 @@ import com.google.android.exoplayer2.util.Util; boolean formatRequired, boolean loadingFinished, Format downstreamFormat, SampleExtrasHolder extrasHolder) { if (!hasNextSample()) { - if (loadingFinished) { + if (loadingFinished || isLastSampleQueued) { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; } else if (upstreamFormat != null @@ -388,7 +404,9 @@ import com.google.android.exoplayer2.util.Util; upstreamKeyframeRequired = false; } Assertions.checkState(!upstreamFormatRequired); - commitSampleTimestamp(timeUs); + + isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); int relativeEndIndex = getRelativeIndex(length); timesUs[relativeEndIndex] = timeUs; @@ -439,10 +457,6 @@ import com.google.android.exoplayer2.util.Util; } } - public synchronized void commitSampleTimestamp(long timeUs) { - largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); - } - /** * Attempts to discard samples from the end of the queue to allow samples starting from the * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index ecc720c656..0886e79d21 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -224,6 +224,15 @@ public class SampleQueue implements TrackOutput { return metadataQueue.getLargestQueuedTimestampUs(); } + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + */ + public boolean isLastSampleQueued() { + return metadataQueue.isLastSampleQueued(); + } + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ public long getFirstTimestampUs() { return metadataQueue.getFirstTimestampUs(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 966938262e..d13fa06434 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.source; import android.net.Uri; import android.os.Handler; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; @@ -303,10 +302,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } @Override - public void prepareSourceInternal( - ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { transferListener = mediaTransferListener; refreshSourceInfo(timeline, /* manifest= */ null); } @@ -317,7 +313,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { return new SingleSampleMediaPeriod( dataSpec, dataSourceFactory, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java index f041542356..51de225414 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -15,9 +15,10 @@ */ package com.google.android.exoplayer2.source.ads; +import android.support.annotation.Nullable; import android.view.ViewGroup; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; @@ -30,16 +31,16 @@ import java.io.IOException; * with a new copy of the current {@link AdPlaybackState} whenever further information about ads * becomes known (for example, when an ad media URI is available, or an ad has played to the end). * - *

        {@link #attachPlayer(ExoPlayer, EventListener, ViewGroup)} will be called when the ads media - * source first initializes, at which point the loader can request ads. If the player enters the - * background, {@link #detachPlayer()} will be called. Loaders should maintain any ad playback state - * in preparation for a later call to {@link #attachPlayer(ExoPlayer, EventListener, ViewGroup)}. If - * an ad is playing when the player is detached, update the ad playback state with the current - * playback position using {@link AdPlaybackState#withAdResumePositionUs(long)}. + *

        {@link #start(EventListener, ViewGroup)} will be called when the ads media source first + * initializes, at which point the loader can request ads. If the player enters the background, + * {@link #stop()} will be called. Loaders should maintain any ad playback state in preparation for + * a later call to {@link #start(EventListener, ViewGroup)}. If an ad is playing when the player is + * detached, update the ad playback state with the current playback position using {@link + * AdPlaybackState#withAdResumePositionUs(long)}. * *

        If {@link EventListener#onAdPlaybackState(AdPlaybackState)} has been called, the - * implementation of {@link #attachPlayer(ExoPlayer, EventListener, ViewGroup)} should invoke the - * same listener to provide the existing playback state to the new player. + * implementation of {@link #start(EventListener, ViewGroup)} should invoke the same listener to + * provide the existing playback state to the new player. */ public interface AdsLoader { @@ -75,9 +76,34 @@ public interface AdsLoader { } + // Methods called by the application. + /** - * Sets the supported content types for ad media. Must be called before the first call to - * {@link #attachPlayer(ExoPlayer, EventListener, ViewGroup)}. Subsequent calls may be ignored. + * Sets the player that will play the loaded ads. + * + *

        This method must be called before the player is prepared with media using this ads loader. + * + *

        This method must also be called on the main thread and only players which are accessed on + * the main thread are supported ({@code player.getApplicationLooper() == + * Looper.getMainLooper()}). + * + * @param player The player instance that will play the loaded ads. May be null to delete the + * reference to a previously set player. + */ + void setPlayer(@Nullable Player player); + + /** + * Releases the loader. Must be called by the application on the main thread when the instance is + * no longer needed. + */ + void release(); + + // Methods called by AdsMediaSource. + + /** + * Sets the supported content types for ad media. Must be called before the first call to {@link + * #start(EventListener, ViewGroup)}. Subsequent calls may be ignored. Called on the main thread + * by {@link AdsMediaSource}. * * @param contentTypes The supported content types for ad media. Each element must be one of * {@link C#TYPE_DASH}, {@link C#TYPE_HLS}, {@link C#TYPE_SS} and {@link C#TYPE_OTHER}. @@ -85,32 +111,23 @@ public interface AdsLoader { void setSupportedContentTypes(@C.ContentType int... contentTypes); /** - * Attaches a player that will play ads loaded using this instance. Called on the main thread by - * {@link AdsMediaSource}. + * Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}. * - * @param player The player instance that will play the loaded ads. Only players which are - * accessed on the main thread are supported ({@code player.getApplicationLooper() == - * Looper.getMainLooper()}). * @param eventListener Listener for ads loader events. * @param adUiViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. */ - void attachPlayer(ExoPlayer player, EventListener eventListener, ViewGroup adUiViewGroup); + void start(EventListener eventListener, ViewGroup adUiViewGroup); /** - * Detaches the attached player and event listener. Called on the main thread by - * {@link AdsMediaSource}. + * Stops using the ads loader for playback and deregisters the event listener. Called on the main + * thread by {@link AdsMediaSource}. */ - void detachPlayer(); - - /** - * Releases the loader. Called by the application on the main thread when the instance is no - * longer needed. - */ - void release(); + void stop(); /** * Notifies the ads loader that the player was not able to prepare media for a given ad. * Implementations should update the ad playback state as the specified ad has failed to load. + * Called on the main thread by {@link AdsMediaSource}. * * @param adGroupIndex The index of the ad group. * @param adIndexInAdGroup The index of the ad in the ad group. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 19ddbd2c54..4754466235 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -22,7 +22,6 @@ import android.support.annotation.IntDef; import android.support.annotation.Nullable; import android.view.ViewGroup; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.DeferredMediaPeriod; @@ -326,22 +325,16 @@ public final class AdsMediaSource extends CompositeMediaSource { } @Override - public void prepareSourceInternal( - final ExoPlayer player, - boolean isTopLevelSource, - @Nullable TransferListener mediaTransferListener) { - super.prepareSourceInternal(player, isTopLevelSource, mediaTransferListener); - Assertions.checkArgument( - isTopLevelSource, - "AdsMediaSource must be the top-level source used to prepare the player."); - final ComponentListener componentListener = new ComponentListener(); + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + ComponentListener componentListener = new ComponentListener(); this.componentListener = componentListener; prepareChildSource(DUMMY_CONTENT_MEDIA_PERIOD_ID, contentMediaSource); - mainHandler.post(() -> adsLoader.attachPlayer(player, componentListener, adUiViewGroup)); + mainHandler.post(() -> adsLoader.start(componentListener, adUiViewGroup)); } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { if (adPlaybackState.adGroupCount > 0 && id.isAd()) { int adGroupIndex = id.adGroupIndex; int adIndexInAdGroup = id.adIndexInAdGroup; @@ -360,7 +353,8 @@ public final class AdsMediaSource extends CompositeMediaSource { prepareChildSource(id, adMediaSource); } MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; - DeferredMediaPeriod deferredMediaPeriod = new DeferredMediaPeriod(mediaSource, id, allocator); + DeferredMediaPeriod deferredMediaPeriod = + new DeferredMediaPeriod(mediaSource, id, allocator, startPositionUs); deferredMediaPeriod.setPrepareErrorListener( new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); List mediaPeriods = deferredMediaPeriodByAdMediaSource.get(mediaSource); @@ -376,7 +370,8 @@ public final class AdsMediaSource extends CompositeMediaSource { } return deferredMediaPeriod; } else { - DeferredMediaPeriod mediaPeriod = new DeferredMediaPeriod(contentMediaSource, id, allocator); + DeferredMediaPeriod mediaPeriod = + new DeferredMediaPeriod(contentMediaSource, id, allocator, startPositionUs); mediaPeriod.createPeriod(id); return mediaPeriod; } @@ -404,7 +399,7 @@ public final class AdsMediaSource extends CompositeMediaSource { adPlaybackState = null; adGroupMediaSources = new MediaSource[0][]; adGroupTimelines = new Timeline[0][]; - mainHandler.post(adsLoader::detachPlayer); + mainHandler.post(adsLoader::stop); } @Override @@ -444,6 +439,7 @@ public final class AdsMediaSource extends CompositeMediaSource { } private void onContentSourceInfoRefreshed(Timeline timeline, Object manifest) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); contentTimeline = timeline; contentManifest = manifest; maybeUpdateSourceInfo(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 165b45451b..a1f7a9ee5e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -292,7 +292,6 @@ public final class Cea608Decoder extends CeaDecoder { protected void decode(SubtitleInputBuffer inputBuffer) { ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit()); boolean captionDataProcessed = false; - boolean isRepeatableControl = false; while (ccData.bytesLeft() >= packetLength) { byte ccHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER : (byte) ccData.readUnsignedByte(); @@ -323,6 +322,9 @@ public final class Cea608Decoder extends CeaDecoder { continue; } + boolean repeatedControlPossible = repeatableControlSet; + repeatableControlSet = false; + boolean previousCaptionValid = captionValid; captionValid = (ccHeader & CC_VALID_FLAG) == CC_VALID_FLAG; if (!captionValid) { @@ -372,7 +374,7 @@ public final class Cea608Decoder extends CeaDecoder { // Control character. // ccData1 - 0|0|0|X|X|X|X|X if ((ccData1 & 0xE0) == 0x00) { - isRepeatableControl = handleCtrl(ccData1, ccData2); + handleCtrl(ccData1, ccData2, repeatedControlPossible); continue; } @@ -384,32 +386,24 @@ public final class Cea608Decoder extends CeaDecoder { } if (captionDataProcessed) { - if (!isRepeatableControl) { - repeatableControlSet = false; - } if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { cues = getDisplayCues(); } } } - private boolean handleCtrl(byte cc1, byte cc2) { - boolean isRepeatableControl = isRepeatable(cc1); - - // Most control commands are sent twice in succession to ensure they are received properly. - // We don't want to process duplicate commands, so if we see the same repeatable command twice - // in a row, ignore the second one. - if (isRepeatableControl) { - if (repeatableControlSet - && repeatableControlCc1 == cc1 - && repeatableControlCc2 == cc2) { - // This is a duplicate. Clear the repeatable control flag and return. - repeatableControlSet = false; - return true; + private void handleCtrl(byte cc1, byte cc2, boolean repeatedControlPossible) { + // Most control commands are sent twice in succession to ensure they are received properly. We + // don't want to process duplicate commands, so if we see the same repeatable command twice in a + // row then we ignore the second one. + if (isRepeatable(cc1)) { + if (repeatedControlPossible && repeatableControlCc1 == cc1 && repeatableControlCc2 == cc2) { + // This is a repeated command, so we ignore it. + return; } else { - // This is a repeatable command, but we haven't see it yet, so set the repeatable control - // flag (to ensure we ignore the next one should it be a duplicate) and continue processing - // the command. + // This is the first occurrence of a repeatable command. Set the repeatable control + // variables so that we can recognize and ignore a duplicate (if there is one), and then + // continue to process the command below. repeatableControlSet = true; repeatableControlCc1 = cc1; repeatableControlCc2 = cc2; @@ -421,12 +415,10 @@ public final class Cea608Decoder extends CeaDecoder { } else if (isPreambleAddressCode(cc1, cc2)) { handlePreambleAddressCode(cc1, cc2); } else if (isTabCtrlCode(cc1, cc2)) { - currentCueBuilder.setTab(cc2 - 0x20); + currentCueBuilder.tabOffset = cc2 - 0x20; } else if (isMiscCode(cc1, cc2)) { handleMiscCode(cc2); } - - return isRepeatableControl; } private void handleMidrowCtrl(byte cc2) { @@ -456,12 +448,12 @@ public final class Cea608Decoder extends CeaDecoder { row++; } - if (row != currentCueBuilder.getRow()) { + if (row != currentCueBuilder.row) { if (captionMode != CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { currentCueBuilder = new CueBuilder(captionMode, captionRowCount); cueBuilders.add(currentCueBuilder); } - currentCueBuilder.setRow(row); + currentCueBuilder.row = row; } // cc2 - 0|1|N|0|STYLE|U @@ -475,7 +467,7 @@ public final class Cea608Decoder extends CeaDecoder { currentCueBuilder.setStyle(isCursor ? STYLE_UNCHANGED : cursorOrStyle, underline); if (isCursor) { - currentCueBuilder.setIndent(COLUMN_INDICES[cursorOrStyle]); + currentCueBuilder.indent = COLUMN_INDICES[cursorOrStyle]; } } @@ -542,13 +534,34 @@ public final class Cea608Decoder extends CeaDecoder { } private List getDisplayCues() { - List displayCues = new ArrayList<>(); - for (int i = 0; i < cueBuilders.size(); i++) { - Cue cue = cueBuilders.get(i).build(); + // CEA-608 does not define middle and end alignment, however content providers artificially + // introduce them using whitespace. When each cue is built, we try and infer the alignment based + // on the amount of whitespace either side of the text. To avoid consecutive cues being aligned + // differently, we force all cues to have the same alignment, with start alignment given + // preference, then middle alignment, then end alignment. + @Cue.AnchorType int positionAnchor = Cue.ANCHOR_TYPE_END; + int cueBuilderCount = cueBuilders.size(); + List cueBuilderCues = new ArrayList<>(cueBuilderCount); + for (int i = 0; i < cueBuilderCount; i++) { + Cue cue = cueBuilders.get(i).build(/* forcedPositionAnchor= */ Cue.TYPE_UNSET); + cueBuilderCues.add(cue); if (cue != null) { + positionAnchor = Math.min(positionAnchor, cue.positionAnchor); + } + } + + // Skip null cues and rebuild any that don't have the preferred alignment. + List displayCues = new ArrayList<>(cueBuilderCount); + for (int i = 0; i < cueBuilderCount; i++) { + Cue cue = cueBuilderCues.get(i); + if (cue != null) { + if (cue.positionAnchor != positionAnchor) { + cue = cueBuilders.get(i).build(positionAnchor); + } displayCues.add(cue); } } + return displayCues; } @@ -672,6 +685,12 @@ public final class Cea608Decoder extends CeaDecoder { tabOffset = 0; } + public boolean isEmpty() { + return cueStyles.isEmpty() + && rolledUpCaptions.isEmpty() + && captionStringBuilder.length() == 0; + } + public void setCaptionMode(int captionMode) { this.captionMode = captionMode; } @@ -680,10 +699,8 @@ public final class Cea608Decoder extends CeaDecoder { this.captionRowCount = captionRowCount; } - public boolean isEmpty() { - return cueStyles.isEmpty() - && rolledUpCaptions.isEmpty() - && captionStringBuilder.length() == 0; + public void setStyle(int style, boolean underline) { + cueStyles.add(new CueStyle(style, underline, captionStringBuilder.length())); } public void backspace() { @@ -703,16 +720,12 @@ public final class Cea608Decoder extends CeaDecoder { } } - public int getRow() { - return row; - } - - public void setRow(int row) { - this.row = row; + public void append(char text) { + captionStringBuilder.append(text); } public void rollUp() { - rolledUpCaptions.add(buildSpannableString()); + rolledUpCaptions.add(buildCurrentLine()); captionStringBuilder.setLength(0); cueStyles.clear(); int numRows = Math.min(captionRowCount, row); @@ -721,23 +734,89 @@ public final class Cea608Decoder extends CeaDecoder { } } - public void setIndent(int indent) { - this.indent = indent; + public Cue build(@Cue.AnchorType int forcedPositionAnchor) { + 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(buildCurrentLine()); + + if (cueString.length() == 0) { + // The cue is empty. + return null; + } + + int positionAnchor; + // The number of empty columns before the start of the text, in the range [0-31]. + int startPadding = indent + tabOffset; + // The number of empty columns after the end of the text, in the same range. + int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); + int startEndPaddingDelta = startPadding - endPadding; + if (forcedPositionAnchor != Cue.TYPE_UNSET) { + positionAnchor = forcedPositionAnchor; + } else if (captionMode == CC_MODE_POP_ON + && (Math.abs(startEndPaddingDelta) < 3 || endPadding < 0)) { + // Treat approximately centered pop-on captions as middle aligned. We also treat captions + // that are wider than they should be in this way. See + // https://github.com/google/ExoPlayer/issues/3534. + positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) { + // Treat pop-on captions with less padding at the end than the start as end aligned. + positionAnchor = Cue.ANCHOR_TYPE_END; + } else { + // For all other cases assume start aligned. + positionAnchor = Cue.ANCHOR_TYPE_START; + } + + float position; + switch (positionAnchor) { + case Cue.ANCHOR_TYPE_MIDDLE: + position = 0.5f; + break; + case Cue.ANCHOR_TYPE_END: + position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + break; + case Cue.ANCHOR_TYPE_START: + default: + position = (float) startPadding / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + break; + } + + int lineAnchor; + int line; + // Note: Row indices are in the range [1-15]. + if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { + lineAnchor = Cue.ANCHOR_TYPE_END; + line = row - BASE_ROW; + // Two line adjustments. The first is because line indices from the bottom of the window + // start from -1 rather than 0. The second is a blank row to act as the safe area. + line -= 2; + } else { + lineAnchor = Cue.ANCHOR_TYPE_START; + // Line indices from the top of the window start from 0, but we want a blank row to act as + // the safe area. As a result no adjustment is necessary. + line = row; + } + + return new Cue( + cueString, + Alignment.ALIGN_NORMAL, + line, + Cue.LINE_TYPE_NUMBER, + lineAnchor, + position, + positionAnchor, + Cue.DIMEN_UNSET); } - public void setTab(int tabs) { - tabOffset = tabs; - } - - public void setStyle(int style, boolean underline) { - cueStyles.add(new CueStyle(style, underline, captionStringBuilder.length())); - } - - public void append(char text) { - captionStringBuilder.append(text); - } - - public SpannableString buildSpannableString() { + private SpannableString buildCurrentLine() { SpannableStringBuilder builder = new SpannableStringBuilder(captionStringBuilder); int length = builder.length(); @@ -803,73 +882,6 @@ public final class Cea608Decoder extends CeaDecoder { return new SpannableString(builder); } - public Cue build() { - SpannableStringBuilder cueString = new SpannableStringBuilder(); - // Add any rolled up captions, separated by new lines. - for (int i = 0; i < rolledUpCaptions.size(); i++) { - cueString.append(rolledUpCaptions.get(i)); - cueString.append('\n'); - } - // Add the current line. - cueString.append(buildSpannableString()); - - if (cueString.length() == 0) { - // The cue is empty. - return null; - } - - float position; - int positionAnchor; - // The number of empty columns before the start of the text, in the range [0-31]. - int startPadding = indent + tabOffset; - // The number of empty columns after the end of the text, in the same range. - int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); - int startEndPaddingDelta = startPadding - endPadding; - if (captionMode == CC_MODE_POP_ON && (Math.abs(startEndPaddingDelta) < 3 || endPadding < 0)) { - // Treat approximately centered pop-on captions as middle aligned. We also treat captions - // that are wider than they should be in this way. See - // https://github.com/google/ExoPlayer/issues/3534. - position = 0.5f; - positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; - } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) { - // Treat pop-on captions with less padding at the end than the start as end aligned. - position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH; - // Adjust the position to fit within the safe area. - position = position * 0.8f + 0.1f; - positionAnchor = Cue.ANCHOR_TYPE_END; - } else { - // For all other cases assume start aligned. - position = (float) startPadding / SCREEN_CHARWIDTH; - // Adjust the position to fit within the safe area. - position = position * 0.8f + 0.1f; - positionAnchor = Cue.ANCHOR_TYPE_START; - } - - int lineAnchor; - int line; - // Note: Row indices are in the range [1-15]. - if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { - lineAnchor = Cue.ANCHOR_TYPE_END; - line = row - BASE_ROW; - // Two line adjustments. The first is because line indices from the bottom of the window - // start from -1 rather than 0. The second is a blank row to act as the safe area. - line -= 2; - } else { - lineAnchor = Cue.ANCHOR_TYPE_START; - // Line indices from the top of the window start from 0, but we want a blank row to act as - // the safe area. As a result no adjustment is necessary. - line = row; - } - - return new Cue(cueString, Alignment.ALIGN_NORMAL, line, Cue.LINE_TYPE_NUMBER, lineAnchor, - position, positionAnchor, Cue.DIMEN_UNSET); - } - - @Override - public String toString() { - return captionStringBuilder.toString(); - } - private static void setUnderlineSpan(SpannableStringBuilder builder, int start, int end) { builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java index 2e868077a5..b39f467968 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -68,6 +68,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { private static final String ATTR_END = "end"; private static final String ATTR_STYLE = "style"; private static final String ATTR_REGION = "region"; + private static final String ATTR_IMAGE = "backgroundImage"; private static final Pattern CLOCK_TIME = Pattern.compile("^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])" @@ -77,6 +78,8 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { private static final Pattern FONT_SIZE = Pattern.compile("^(([0-9]*.)?[0-9]+)(px|em|%)$"); private static final Pattern PERCENTAGE_COORDINATES = Pattern.compile("^(\\d+\\.?\\d*?)% (\\d+\\.?\\d*?)%$"); + private static final Pattern PIXEL_COORDINATES = + Pattern.compile("^(\\d+\\.?\\d*?)px (\\d+\\.?\\d*?)px$"); private static final Pattern CELL_RESOLUTION = Pattern.compile("^(\\d+) (\\d+)$"); private static final int DEFAULT_FRAME_RATE = 30; @@ -105,6 +108,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { XmlPullParser xmlParser = xmlParserFactory.newPullParser(); Map globalStyles = new HashMap<>(); Map regionMap = new HashMap<>(); + Map imageMap = new HashMap<>(); regionMap.put(TtmlNode.ANONYMOUS_REGION_ID, new TtmlRegion(null)); ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes, 0, length); xmlParser.setInput(inputStream, null); @@ -114,6 +118,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { int eventType = xmlParser.getEventType(); FrameAndTickRate frameAndTickRate = DEFAULT_FRAME_AND_TICK_RATE; CellResolution cellResolution = DEFAULT_CELL_RESOLUTION; + TtsExtent ttsExtent = null; while (eventType != XmlPullParser.END_DOCUMENT) { TtmlNode parent = nodeStack.peek(); if (unsupportedNodeDepth == 0) { @@ -122,12 +127,13 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { if (TtmlNode.TAG_TT.equals(name)) { frameAndTickRate = parseFrameAndTickRates(xmlParser); cellResolution = parseCellResolution(xmlParser, DEFAULT_CELL_RESOLUTION); + ttsExtent = parseTtsExtent(xmlParser); } if (!isSupportedTag(name)) { Log.i(TAG, "Ignoring unsupported tag: " + xmlParser.getName()); unsupportedNodeDepth++; } else if (TtmlNode.TAG_HEAD.equals(name)) { - parseHeader(xmlParser, globalStyles, regionMap, cellResolution); + parseHeader(xmlParser, globalStyles, cellResolution, ttsExtent, regionMap, imageMap); } else { try { TtmlNode node = parseNode(xmlParser, parent, regionMap, frameAndTickRate); @@ -145,7 +151,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { parent.addChild(TtmlNode.buildTextNode(xmlParser.getText())); } else if (eventType == XmlPullParser.END_TAG) { if (xmlParser.getName().equals(TtmlNode.TAG_TT)) { - ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap); + ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap, imageMap); } nodeStack.pop(); } @@ -226,11 +232,34 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { } } + private TtsExtent parseTtsExtent(XmlPullParser xmlParser) { + String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); + if (ttsExtent == null) { + return null; + } + + Matcher extentMatcher = PIXEL_COORDINATES.matcher(ttsExtent); + if (!extentMatcher.matches()) { + Log.w(TAG, "Ignoring non-pixel tts extent: " + ttsExtent); + return null; + } + try { + int width = Integer.parseInt(extentMatcher.group(1)); + int height = Integer.parseInt(extentMatcher.group(2)); + return new TtsExtent(width, height); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed tts extent: " + ttsExtent); + return null; + } + } + private Map parseHeader( XmlPullParser xmlParser, Map globalStyles, + CellResolution cellResolution, + TtsExtent ttsExtent, Map globalRegions, - CellResolution cellResolution) + Map imageMap) throws IOException, XmlPullParserException { do { xmlParser.next(); @@ -246,23 +275,41 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { globalStyles.put(style.getId(), style); } } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_REGION)) { - TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution); + TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution, ttsExtent); if (ttmlRegion != null) { globalRegions.put(ttmlRegion.id, ttmlRegion); } + } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_METADATA)) { + parseMetadata(xmlParser, imageMap); } } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_HEAD)); return globalStyles; } + private void parseMetadata(XmlPullParser xmlParser, Map imageMap) + throws IOException, XmlPullParserException { + do { + xmlParser.next(); + if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_IMAGE)) { + String id = XmlPullParserUtil.getAttributeValue(xmlParser, "id"); + if (id != null) { + String encodedBitmapData = xmlParser.nextText(); + imageMap.put(id, encodedBitmapData); + } + } + } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_METADATA)); + } + /** * Parses a region declaration. * - *

        If the region defines an origin and extent, it is required that they're defined as - * percentages of the viewport. Region declarations that define origin and extent in other formats - * are unsupported, and null is returned. + *

        Supports both percentage and pixel defined regions. In case of pixel defined regions the + * passed {@code ttsExtent} is used as a reference window to convert the pixel values to + * fractions. In case of missing tts:extent the pixel defined regions can't be parsed, and null is + * returned. */ - private TtmlRegion parseRegionAttributes(XmlPullParser xmlParser, CellResolution cellResolution) { + private TtmlRegion parseRegionAttributes( + XmlPullParser xmlParser, CellResolution cellResolution, TtsExtent ttsExtent) { String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID); if (regionId == null) { return null; @@ -270,13 +317,30 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { float position; float line; + String regionOrigin = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_ORIGIN); if (regionOrigin != null) { - Matcher originMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); - if (originMatcher.matches()) { + Matcher originPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); + Matcher originPixelMatcher = PIXEL_COORDINATES.matcher(regionOrigin); + if (originPercentageMatcher.matches()) { try { - position = Float.parseFloat(originMatcher.group(1)) / 100f; - line = Float.parseFloat(originMatcher.group(2)) / 100f; + position = Float.parseFloat(originPercentageMatcher.group(1)) / 100f; + line = Float.parseFloat(originPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); + return null; + } + } else if (originPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int width = Integer.parseInt(originPixelMatcher.group(1)); + int height = Integer.parseInt(originPixelMatcher.group(2)); + // Convert pixel values to fractions. + position = width / (float) ttsExtent.width; + line = height / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); return null; @@ -299,11 +363,27 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { float height; String regionExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); if (regionExtent != null) { - Matcher extentMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); - if (extentMatcher.matches()) { + Matcher extentPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); + Matcher extentPixelMatcher = PIXEL_COORDINATES.matcher(regionExtent); + if (extentPercentageMatcher.matches()) { try { - width = Float.parseFloat(extentMatcher.group(1)) / 100f; - height = Float.parseFloat(extentMatcher.group(2)) / 100f; + width = Float.parseFloat(extentPercentageMatcher.group(1)) / 100f; + height = Float.parseFloat(extentPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); + return null; + } + } else if (extentPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int extentWidth = Integer.parseInt(extentPixelMatcher.group(1)); + int extentHeight = Integer.parseInt(extentPixelMatcher.group(2)); + // Convert pixel values to fractions. + width = extentWidth / (float) ttsExtent.width; + height = extentHeight / (float) ttsExtent.height; } catch (NumberFormatException e) { Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); return null; @@ -457,6 +537,7 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { long startTime = C.TIME_UNSET; long endTime = C.TIME_UNSET; String regionId = TtmlNode.ANONYMOUS_REGION_ID; + String imageId = null; String[] styleIds = null; int attributeCount = parser.getAttributeCount(); TtmlStyle style = parseStyleAttributes(parser, null); @@ -487,6 +568,13 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { regionId = value; } break; + case ATTR_IMAGE: + // Parse URI reference only if refers to an element in the same document (it must start + // with '#'). Resolving URIs from external sources is not supported. + if (value.startsWith("#")) { + imageId = value.substring(1); + } + break; default: // Do nothing. break; @@ -509,7 +597,8 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { endTime = parent.endTimeUs; } } - return TtmlNode.buildNode(parser.getName(), startTime, endTime, style, styleIds, regionId); + return TtmlNode.buildNode( + parser.getName(), startTime, endTime, style, styleIds, regionId, imageId); } private static boolean isSupportedTag(String tag) { @@ -525,9 +614,9 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { || tag.equals(TtmlNode.TAG_LAYOUT) || tag.equals(TtmlNode.TAG_REGION) || tag.equals(TtmlNode.TAG_METADATA) - || tag.equals(TtmlNode.TAG_SMPTE_IMAGE) - || tag.equals(TtmlNode.TAG_SMPTE_DATA) - || tag.equals(TtmlNode.TAG_SMPTE_INFORMATION); + || tag.equals(TtmlNode.TAG_IMAGE) + || tag.equals(TtmlNode.TAG_DATA) + || tag.equals(TtmlNode.TAG_INFORMATION); } private static void parseFontSize(String expression, TtmlStyle out) throws @@ -651,4 +740,15 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { this.rows = rows; } } + + /** Represents the tts:extent for a TTML file. */ + private static final class TtsExtent { + final int width; + final int height; + + TtsExtent(int width, int height) { + this.width = width; + this.height = height; + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index c8b9a59de4..020bbe201b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -15,7 +15,12 @@ */ package com.google.android.exoplayer2.text.ttml; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.annotation.Nullable; import android.text.SpannableStringBuilder; +import android.util.Base64; +import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.util.Assertions; @@ -44,9 +49,9 @@ import java.util.TreeSet; public static final String TAG_LAYOUT = "layout"; public static final String TAG_REGION = "region"; public static final String TAG_METADATA = "metadata"; - public static final String TAG_SMPTE_IMAGE = "smpte:image"; - public static final String TAG_SMPTE_DATA = "smpte:data"; - public static final String TAG_SMPTE_INFORMATION = "smpte:information"; + public static final String TAG_IMAGE = "image"; + public static final String TAG_DATA = "data"; + public static final String TAG_INFORMATION = "information"; public static final String ANONYMOUS_REGION_ID = ""; public static final String ATTR_ID = "id"; @@ -75,34 +80,57 @@ import java.util.TreeSet; public static final String START = "start"; public static final String END = "end"; - public final String tag; - public final String text; + @Nullable public final String tag; + @Nullable public final String text; public final boolean isTextNode; public final long startTimeUs; public final long endTimeUs; - public final TtmlStyle style; + @Nullable public final TtmlStyle style; + @Nullable private final String[] styleIds; public final String regionId; + @Nullable public final String imageId; - private final String[] styleIds; private final HashMap nodeStartsByRegion; private final HashMap nodeEndsByRegion; private List children; public static TtmlNode buildTextNode(String text) { - return new TtmlNode(null, TtmlRenderUtil.applyTextElementSpacePolicy(text), C.TIME_UNSET, - C.TIME_UNSET, null, null, ANONYMOUS_REGION_ID); + return new TtmlNode( + /* tag= */ null, + TtmlRenderUtil.applyTextElementSpacePolicy(text), + /* startTimeUs= */ C.TIME_UNSET, + /* endTimeUs= */ C.TIME_UNSET, + /* style= */ null, + /* styleIds= */ null, + ANONYMOUS_REGION_ID, + /* imageId= */ null); } - public static TtmlNode buildNode(String tag, long startTimeUs, long endTimeUs, - TtmlStyle style, String[] styleIds, String regionId) { - return new TtmlNode(tag, null, startTimeUs, endTimeUs, style, styleIds, regionId); + public static TtmlNode buildNode( + @Nullable String tag, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { + return new TtmlNode( + tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId); } - private TtmlNode(String tag, String text, long startTimeUs, long endTimeUs, - TtmlStyle style, String[] styleIds, String regionId) { + private TtmlNode( + @Nullable String tag, + @Nullable String text, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { this.tag = tag; this.text = text; + this.imageId = imageId; this.style = style; this.styleIds = styleIds; this.isTextNode = text != null; @@ -151,7 +179,8 @@ import java.util.TreeSet; private void getEventTimes(TreeSet out, boolean descendsPNode) { boolean isPNode = TAG_P.equals(tag); - if (descendsPNode || isPNode) { + boolean isDivNode = TAG_DIV.equals(tag); + if (descendsPNode || isPNode || (isDivNode && imageId != null)) { if (startTimeUs != C.TIME_UNSET) { out.add(startTimeUs); } @@ -171,13 +200,46 @@ import java.util.TreeSet; return styleIds; } - public List getCues(long timeUs, Map globalStyles, - Map regionMap) { - TreeMap regionOutputs = new TreeMap<>(); - traverseForText(timeUs, false, regionId, regionOutputs); - traverseForStyle(timeUs, globalStyles, regionOutputs); + public List getCues( + long timeUs, + Map globalStyles, + Map regionMap, + Map imageMap) { + + List> regionImageOutputs = new ArrayList<>(); + traverseForImage(timeUs, regionId, regionImageOutputs); + + TreeMap regionTextOutputs = new TreeMap<>(); + traverseForText(timeUs, false, regionId, regionTextOutputs); + traverseForStyle(timeUs, globalStyles, regionTextOutputs); + List cues = new ArrayList<>(); - for (Entry entry : regionOutputs.entrySet()) { + + // Create image based cues. + for (Pair regionImagePair : regionImageOutputs) { + String encodedBitmapData = imageMap.get(regionImagePair.second); + if (encodedBitmapData == null) { + // Image reference points to an invalid image. Do nothing. + continue; + } + + byte[] bitmapData = Base64.decode(encodedBitmapData, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, /* offset= */ 0, bitmapData.length); + TtmlRegion region = regionMap.get(regionImagePair.first); + + cues.add( + new Cue( + bitmap, + region.position, + Cue.ANCHOR_TYPE_MIDDLE, + region.line, + region.lineAnchor, + region.width, + /* height= */ Cue.DIMEN_UNSET)); + } + + // Create text based cues. + for (Entry entry : regionTextOutputs.entrySet()) { TtmlRegion region = regionMap.get(entry.getKey()); cues.add( new Cue( @@ -192,9 +254,22 @@ import java.util.TreeSet; region.textSizeType, region.textSize)); } + return cues; } + private void traverseForImage( + long timeUs, String inheritedRegion, List> regionImageList) { + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + if (isActive(timeUs) && TAG_DIV.equals(tag) && imageId != null) { + regionImageList.add(new Pair<>(resolvedRegionId, imageId)); + return; + } + for (int i = 0; i < getChildCount(); ++i) { + getChild(i).traverseForImage(timeUs, resolvedRegionId, regionImageList); + } + } + private void traverseForText( long timeUs, boolean descendsPNode, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java index 2ac1427e91..1779d9890a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java @@ -33,11 +33,16 @@ import java.util.Map; private final long[] eventTimesUs; private final Map globalStyles; private final Map regionMap; + private final Map imageMap; - public TtmlSubtitle(TtmlNode root, Map globalStyles, - Map regionMap) { + public TtmlSubtitle( + TtmlNode root, + Map globalStyles, + Map regionMap, + Map imageMap) { this.root = root; this.regionMap = regionMap; + this.imageMap = imageMap; this.globalStyles = globalStyles != null ? Collections.unmodifiableMap(globalStyles) : Collections.emptyMap(); this.eventTimesUs = root.getEventTimesUs(); @@ -66,7 +71,7 @@ import java.util.Map; @Override public List getCues(long timeUs) { - return root.getCues(timeUs, globalStyles, regionMap); + return root.getCues(timeUs, globalStyles, regionMap, imageMap); } @VisibleForTesting diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java index 66b49555ef..dd2d0001f8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -227,8 +227,36 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { } @Override - public AdaptiveTrackSelection createTrackSelection( - TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks) { + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + TrackSelection[] selections = new TrackSelection[definitions.length]; + AdaptiveTrackSelection adaptiveSelection = null; + int totalFixedBandwidth = 0; + for (int i = 0; i < definitions.length; i++) { + Definition definition = definitions[i]; + if (definition == null) { + continue; + } + if (definition.tracks.length > 1) { + adaptiveSelection = + createAdaptiveTrackSelection(definition.group, bandwidthMeter, definition.tracks); + selections[i] = adaptiveSelection; + } else { + selections[i] = new FixedTrackSelection(definition.group, definition.tracks[0]); + int trackBitrate = definition.group.getFormat(definition.tracks[0]).bitrate; + if (trackBitrate != Format.NO_VALUE) { + totalFixedBandwidth += trackBitrate; + } + } + } + if (blockFixedTrackSelectionBandwidth && adaptiveSelection != null) { + adaptiveSelection.experimental_setNonAllocatableBandwidth(totalFixedBandwidth); + } + return selections; + } + + private AdaptiveTrackSelection createAdaptiveTrackSelection( + TrackGroup group, BandwidthMeter bandwidthMeter, int[] tracks) { if (this.bandwidthMeter != null) { bandwidthMeter = this.bandwidthMeter; } @@ -246,34 +274,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { adaptiveTrackSelection.experimental_setTrackBitrateEstimator(trackBitrateEstimator); return adaptiveTrackSelection; } - - @Override - public @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { - TrackSelection[] selections = new TrackSelection[definitions.length]; - AdaptiveTrackSelection adaptiveSelection = null; - int totalFixedBandwidth = 0; - for (int i = 0; i < definitions.length; i++) { - Definition definition = definitions[i]; - if (definition == null) { - continue; - } - if (definition.tracks.length > 1) { - selections[i] = createTrackSelection(definition.group, bandwidthMeter, definition.tracks); - adaptiveSelection = (AdaptiveTrackSelection) selections[i]; - } else { - selections[i] = new FixedTrackSelection(definition.group, definition.tracks[0]); - int trackBitrate = definition.group.getFormat(definition.tracks[0]).bitrate; - if (trackBitrate != Format.NO_VALUE) { - totalFixedBandwidth += trackBitrate; - } - } - } - if (blockFixedTrackSelectionBandwidth && adaptiveSelection != null) { - adaptiveSelection.experimental_setNonAllocatableBandwidth(totalFixedBandwidth); - } - return selections; - } } public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10000; @@ -391,7 +391,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; this.clock = clock; playbackSpeed = 1f; - reason = C.SELECTION_REASON_INITIAL; + reason = C.SELECTION_REASON_UNKNOWN; lastBufferEvaluationMs = C.TIME_UNSET; trackBitrateEstimator = TrackBitrateEstimator.DEFAULT; formats = new Format[length]; @@ -403,9 +403,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { formats[i] = format; formatBitrates[i] = formats[i].bitrate; } - @SuppressWarnings("nullness:method.invocation.invalid") - int selectedIndex = determineIdealSelectedIndex(Long.MIN_VALUE, formatBitrates); - this.selectedIndex = selectedIndex; } /** @@ -453,6 +450,13 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { // Update the estimated track bitrates. trackBitrateEstimator.getBitrates(formats, queue, mediaChunkIterators, trackBitrates); + // Make initial selection + if (reason == C.SELECTION_REASON_UNKNOWN) { + reason = C.SELECTION_REASON_INITIAL; + selectedIndex = determineIdealSelectedIndex(nowMs, trackBitrates); + return; + } + // Stash the current selection, then make a new one. int currentSelectedIndex = selectedIndex; selectedIndex = determineIdealSelectedIndex(nowMs, trackBitrates); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java index 6239dd04ad..ee1d1c62da 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java @@ -24,12 +24,14 @@ import com.google.android.exoplayer2.LoadControl; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import com.google.android.exoplayer2.trackselection.TrackSelection.Definition; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.PriorityTaskManager; import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * Builder for a {@link TrackSelection.Factory} and {@link LoadControl} that implement buffer size @@ -41,7 +43,7 @@ public final class BufferSizeAdaptationBuilder { public interface DynamicFormatFilter { /** Filter which allows all formats. */ - DynamicFormatFilter NO_FILTER = (format, trackBitrate) -> true; + DynamicFormatFilter NO_FILTER = (format, trackBitrate, isInitialSelection) -> true; /** * Called when updating the selected track to determine whether a candidate track is allowed. If @@ -50,8 +52,9 @@ public final class BufferSizeAdaptationBuilder { * @param format The {@link Format} of the candidate track. * @param trackBitrate The estimated bitrate of the track. May differ from {@link * Format#bitrate} if a more accurate estimate of the current track bitrate is available. + * @param isInitialSelection Whether this is for the initial track selection. */ - boolean isFormatAllowed(Format format, int trackBitrate); + boolean isFormatAllowed(Format format, int trackBitrate, boolean isInitialSelection); } /** @@ -273,19 +276,22 @@ public final class BufferSizeAdaptationBuilder { TrackSelection.Factory trackSelectionFactory = new TrackSelection.Factory() { @Override - public TrackSelection createTrackSelection( - TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks) { - return new BufferSizeAdaptiveTrackSelection( - group, - tracks, - bandwidthMeter, - minBufferMs, - maxBufferMs, - hysteresisBufferMs, - startUpBandwidthFraction, - startUpMinBufferForQualityIncreaseMs, - dynamicFormatFilter, - clock); + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> + new BufferSizeAdaptiveTrackSelection( + definition.group, + definition.tracks, + bandwidthMeter, + minBufferMs, + maxBufferMs, + hysteresisBufferMs, + startUpBandwidthFraction, + startUpMinBufferForQualityIncreaseMs, + dynamicFormatFilter, + clock)); } }; @@ -339,7 +345,7 @@ public final class BufferSizeAdaptationBuilder { formatBitrates = new int[length]; maxBitrate = getFormat(/* index= */ 0).bitrate; minBitrate = getFormat(/* index= */ length - 1).bitrate; - selectionReason = C.SELECTION_REASON_INITIAL; + selectionReason = C.SELECTION_REASON_UNKNOWN; playbackSpeed = 1.0f; // We use a log-linear function to map from bitrate to buffer size: @@ -349,9 +355,6 @@ public final class BufferSizeAdaptationBuilder { (maxBufferUs - hysteresisBufferUs - minBufferUs) / Math.log(maxBitrate / minBitrate); bitrateToBufferFunctionIntercept = minBufferUs - bitrateToBufferFunctionSlope * Math.log(minBitrate); - - updateFormatBitrates(/* nowMs= */ Long.MIN_VALUE); - selectedIndex = selectIdealIndexUsingBandwidth(); } @Override @@ -388,6 +391,14 @@ public final class BufferSizeAdaptationBuilder { List queue, MediaChunkIterator[] mediaChunkIterators) { updateFormatBitrates(/* nowMs= */ clock.elapsedRealtime()); + + // Make initial selection + if (selectionReason == C.SELECTION_REASON_UNKNOWN) { + selectionReason = C.SELECTION_REASON_INITIAL; + selectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ true); + return; + } + long bufferUs = getCurrentPeriodBufferedDurationUs(playbackPositionUs, bufferedDurationUs); int oldSelectedIndex = selectedIndex; if (isInSteadyState) { @@ -423,7 +434,8 @@ public final class BufferSizeAdaptationBuilder { for (int i = 0; i < formatBitrates.length; i++) { if (formatBitrates[i] != BITRATE_BLACKLISTED) { if (getTargetBufferForBitrateUs(formatBitrates[i]) < bufferUs - && dynamicFormatFilter.isFormatAllowed(getFormat(i), formatBitrates[i])) { + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], /* isInitialSelection= */ false)) { return i; } lowestBitrateNonBlacklistedIndex = i; @@ -435,7 +447,7 @@ public final class BufferSizeAdaptationBuilder { // Startup. private void selectIndexStartUpPhase(long bufferUs) { - int startUpSelectedIndex = selectIdealIndexUsingBandwidth(); + int startUpSelectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ false); int steadyStateSelectedIndex = selectIdealIndexUsingBufferSize(bufferUs); if (steadyStateSelectedIndex <= selectedIndex) { // Switch to steady state if we have enough buffer to maintain current selection. @@ -452,14 +464,15 @@ public final class BufferSizeAdaptationBuilder { } } - private int selectIdealIndexUsingBandwidth() { + private int selectIdealIndexUsingBandwidth(boolean isInitialSelection) { long effectiveBitrate = (long) (bandwidthMeter.getBitrateEstimate() * startUpBandwidthFraction); int lowestBitrateNonBlacklistedIndex = 0; for (int i = 0; i < formatBitrates.length; i++) { if (formatBitrates[i] != BITRATE_BLACKLISTED) { if (Math.round(formatBitrates[i] * playbackSpeed) <= effectiveBitrate - && dynamicFormatFilter.isFormatAllowed(getFormat(i), formatBitrates[i])) { + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], isInitialSelection)) { return i; } lowestBitrateNonBlacklistedIndex = i; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java index 7755e437ce..79b5d93dc7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java @@ -21,8 +21,8 @@ import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.upstream.BandwidthMeter; -import com.google.android.exoplayer2.util.Assertions; import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * A {@link TrackSelection} consisting of a single track. @@ -56,10 +56,12 @@ public final class FixedTrackSelection extends BaseTrackSelection { } @Override - public FixedTrackSelection createTrackSelection( - TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks) { - Assertions.checkArgument(tracks.length == 1); - return new FixedTrackSelection(group, tracks[0], reason, data); + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> + new FixedTrackSelection(definition.group, definition.tracks[0], reason, data)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java index e3c643670b..217a16e4a6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.upstream.BandwidthMeter; import java.util.List; import java.util.Random; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * A {@link TrackSelection} whose selected track is updated randomly. @@ -49,9 +50,11 @@ public final class RandomTrackSelection extends BaseTrackSelection { } @Override - public RandomTrackSelection createTrackSelection( - TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks) { - return new RandomTrackSelection(group, tracks, random); + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> new RandomTrackSelection(definition.group, definition.tracks, random)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java index 13e823da29..251c0ac76b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java @@ -21,8 +21,8 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import com.google.android.exoplayer2.trackselection.TrackSelectionUtil.AdaptiveTrackSelectionFactory; import com.google.android.exoplayer2.upstream.BandwidthMeter; -import com.google.android.exoplayer2.util.Assertions; import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; @@ -61,42 +61,31 @@ public interface TrackSelection { interface Factory { /** - * Creates a new selection. - * - * @param group The {@link TrackGroup}. Must not be null. - * @param bandwidthMeter A {@link BandwidthMeter} which can be used to select tracks. - * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be - * null or empty. May be in any order. - * @return The created selection. + * @deprecated Implement {@link #createTrackSelections(Definition[], BandwidthMeter)} instead. + * Calling {@link TrackSelectionUtil#createTrackSelectionsForDefinitions(Definition[], + * AdaptiveTrackSelectionFactory)} helps to create a single adaptive track selection in the + * same way as using this deprecated method. */ - TrackSelection createTrackSelection( - TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks); + @Deprecated + default TrackSelection createTrackSelection( + TrackGroup group, BandwidthMeter bandwidthMeter, int... tracks) { + throw new UnsupportedOperationException(); + } /** * Creates a new selection for each {@link Definition}. * * @param definitions A {@link Definition} array. May include null values. * @param bandwidthMeter A {@link BandwidthMeter} which can be used to select tracks. - * @return The created selections. For null entries in {@code definitions} returns null values. + * @return The created selections. Must have the same length as {@code definitions} and may + * include null values. */ + @SuppressWarnings("deprecation") default @NullableType TrackSelection[] createTrackSelections( @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { - TrackSelection[] selections = new TrackSelection[definitions.length]; - boolean createdAdaptiveTrackSelection = false; - for (int i = 0; i < definitions.length; i++) { - Definition definition = definitions[i]; - if (definition == null) { - continue; - } - if (definition.tracks.length > 1) { - Assertions.checkState(!createdAdaptiveTrackSelection); - createdAdaptiveTrackSelection = true; - selections[i] = createTrackSelection(definition.group, bandwidthMeter, definition.tracks); - } else { - selections[i] = new FixedTrackSelection(definition.group, definition.tracks[0]); - } - } - return selections; + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> createTrackSelection(definition.group, bandwidthMeter, definition.tracks)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java index 947f64be2c..7800495a62 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java @@ -22,15 +22,59 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.source.chunk.MediaChunkListIterator; +import com.google.android.exoplayer2.trackselection.TrackSelection.Definition; import com.google.android.exoplayer2.util.Assertions; import java.util.Arrays; import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** Track selection related utility methods. */ public final class TrackSelectionUtil { private TrackSelectionUtil() {} + /** Functional interface to create a single adaptive track selection. */ + public interface AdaptiveTrackSelectionFactory { + + /** + * Creates an adaptive track selection for the provided track selection definition. + * + * @param trackSelectionDefinition A {@link Definition} for the track selection. + * @return The created track selection. + */ + TrackSelection createAdaptiveTrackSelection(Definition trackSelectionDefinition); + } + + /** + * Creates track selections for an array of track selection definitions, with at most one + * multi-track adaptive selection. + * + * @param definitions The list of track selection {@link Definition definitions}. May include null + * values. + * @param adaptiveTrackSelectionFactory A factory for the multi-track adaptive track selection. + * @return The array of created track selection. For null entries in {@code definitions} returns + * null values. + */ + public static @NullableType TrackSelection[] createTrackSelectionsForDefinitions( + @NullableType Definition[] definitions, + AdaptiveTrackSelectionFactory adaptiveTrackSelectionFactory) { + TrackSelection[] selections = new TrackSelection[definitions.length]; + boolean createdAdaptiveTrackSelection = false; + for (int i = 0; i < definitions.length; i++) { + Definition definition = definitions[i]; + if (definition == null) { + continue; + } + if (definition.tracks.length > 1 && !createdAdaptiveTrackSelection) { + createdAdaptiveTrackSelection = true; + selections[i] = adaptiveTrackSelectionFactory.createAdaptiveTrackSelection(definition); + } else { + selections[i] = new FixedTrackSelection(definition.group, definition.tracks[0]); + } + } + return selections; + } + /** * Returns average bitrate for chunks in bits per second. Chunks are included in average until * {@code maxDurationMs} or the first unknown length chunk. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java index c759499577..ab22e18358 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java @@ -64,11 +64,11 @@ public interface DataSource { long open(DataSpec dataSpec) throws IOException; /** - * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * Reads up to {@code readLength} bytes of data and stores them into {@code buffer}, starting at * index {@code offset}. - *

        - * If {@code length} is zero then 0 is returned. Otherwise, if no data is available because the - * end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is returned. + * + *

        If {@code readLength} is zero then 0 is returned. Otherwise, if no data is available because + * the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is returned. * Otherwise, the call will block until at least one byte of data has been read and the number of * bytes read is returned. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index c33c7c823f..2996de4527 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -31,14 +31,20 @@ import java.util.Arrays; public final class DataSpec { /** - * The flags that apply to any request for data. Possible flag values are {@link #FLAG_ALLOW_GZIP} - * and {@link #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}. + * The flags that apply to any request for data. Possible flag values are {@link + * #FLAG_ALLOW_GZIP}, {@link #FLAG_ALLOW_ICY_METADATA}, {@link #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} + * and {@link #FLAG_ALLOW_CACHE_FRAGMENTATION}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {FLAG_ALLOW_GZIP, FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}) + value = { + FLAG_ALLOW_GZIP, + FLAG_ALLOW_ICY_METADATA, + FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN, + FLAG_ALLOW_CACHE_FRAGMENTATION + }) public @interface Flags {} /** * Allows an underlying network stack to request that the server use gzip compression. @@ -52,9 +58,17 @@ public final class DataSpec { * DataSource#read(byte[], int, int)} will be the decompressed data. */ public static final int FLAG_ALLOW_GZIP = 1; - + /** Allows an underlying network stack to request that the stream contain ICY metadata. */ + public static final int FLAG_ALLOW_ICY_METADATA = 1 << 1; // 2 /** Prevents caching if the length cannot be resolved when the {@link DataSource} is opened. */ - public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 1; // 2 + public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 2; // 4 + /** + * Allows fragmentation of this request into multiple cache files, meaning a cache eviction policy + * will be able to evict individual fragments of the data. Depending on the cache implementation, + * setting this flag may also enable more concurrent access to the data (e.g. reading one fragment + * whilst writing another). + */ + public static final int FLAG_ALLOW_CACHE_FRAGMENTATION = 1 << 4; // 8 /** * The set of HTTP methods that are supported by ExoPlayer {@link HttpDataSource}s. One of {@link @@ -108,10 +122,7 @@ public final class DataSpec { * {@link DataSpec} is not intended to be used in conjunction with a cache. */ public final @Nullable String key; - /** - * Request flags. Currently {@link #FLAG_ALLOW_GZIP} and - * {@link #FLAG_ALLOW_CACHING_UNKNOWN_LENGTH} are the only supported flags. - */ + /** Request {@link Flags flags}. */ public final @Flags int flags; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java index c6749e6c8f..ebeb9a1913 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -19,6 +19,7 @@ import android.net.Uri; import android.support.annotation.Nullable; import android.text.TextUtils; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.metadata.icy.IcyHeaders; import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; @@ -429,12 +430,20 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou long position = dataSpec.position; long length = dataSpec.length; boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + boolean allowIcyMetadata = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA); if (!allowCrossProtocolRedirects) { // HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection // automatically. This is the behavior we want, so use it. return makeConnection( - url, httpMethod, httpBody, position, length, allowGzip, true /* followRedirects */); + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + allowIcyMetadata, + /* followRedirects= */ true); } // We need to handle redirects ourselves to allow cross-protocol redirects. @@ -442,7 +451,14 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou while (redirectCount++ <= MAX_REDIRECTS) { HttpURLConnection connection = makeConnection( - url, httpMethod, httpBody, position, length, allowGzip, false /* followRedirects */); + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + allowIcyMetadata, + /* followRedirects= */ false); int responseCode = connection.getResponseCode(); String location = connection.getHeaderField("Location"); if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) @@ -482,6 +498,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou * @param position The byte offset of the requested data. * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. * @param allowGzip Whether to allow the use of gzip. + * @param allowIcyMetadata Whether to allow ICY metadata. * @param followRedirects Whether to follow redirects. */ private HttpURLConnection makeConnection( @@ -491,6 +508,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou long position, long length, boolean allowGzip, + boolean allowIcyMetadata, boolean followRedirects) throws IOException { HttpURLConnection connection = (HttpURLConnection) url.openConnection(); @@ -515,6 +533,11 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou if (!allowGzip) { connection.setRequestProperty("Accept-Encoding", "identity"); } + if (allowIcyMetadata) { + connection.setRequestProperty( + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME, + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE); + } connection.setInstanceFollowRedirects(followRedirects); connection.setDoOutput(httpBody != null); connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod)); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index a769e9acac..6bbe1edd1d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.cache; import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; import java.io.File; import java.io.IOException; import java.util.NavigableSet; @@ -62,7 +63,7 @@ public interface Cache { void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan); } - + /** * Thrown when an error is encountered when writing data. */ @@ -82,7 +83,7 @@ public interface Cache { * Releases the cache. This method must be called when the cache is no longer required. The cache * must not be used after calling this method. */ - void release() throws CacheException; + void release(); /** * Registers a listener to listen for changes to a given key. @@ -140,8 +141,8 @@ public interface Cache { * obtains the data from some other source. The returned {@link CacheSpan} serves as a lock. * Whilst the caller holds the lock it may write data into the hole. It may split data into * multiple files. When the caller has finished writing a file it should commit it to the cache by - * calling {@link #commitFile(File)}. When the caller has finished writing, it must release the - * lock by calling {@link #releaseHoleSpan}. + * calling {@link #commitFile(File, long)}. When the caller has finished writing, it must release + * the lock by calling {@link #releaseHoleSpan}. * * @param key The key of the data being requested. * @param position The position of the data being requested. @@ -169,21 +170,22 @@ public interface Cache { * * @param key The cache key for the data. * @param position The starting position of the data. - * @param maxLength The maximum length of the data to be written. Used only to ensure that there - * is enough space in the cache. + * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. Used + * only to ensure that there is enough space in the cache. * @return The file into which data should be written. * @throws CacheException If an error is encountered. */ - File startFile(String key, long position, long maxLength) throws CacheException; + File startFile(String key, long position, long length) throws CacheException; /** * Commits a file into the cache. Must only be called when holding a corresponding hole {@link * CacheSpan} obtained from {@link #startReadWrite(String, long)} * * @param file A newly written cache file. + * @param length The length of the newly written cache file in bytes. * @throws CacheException If an error is encountered. */ - void commitFile(File file) throws CacheException; + void commitFile(File file, long length) throws CacheException; /** * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which @@ -223,25 +225,6 @@ public interface Cache { */ long getCachedLength(String key, long position, long length); - /** - * Sets the content length for the given key. - * - * @param key The cache key for the data. - * @param length The length of the data. - * @throws CacheException If an error is encountered. - */ - void setContentLength(String key, long length) throws CacheException; - - /** - * Returns the content length for the given key if one set, or {@link - * com.google.android.exoplayer2.C#LENGTH_UNSET} otherwise. - * - * @param key The cache key for the data. - * @return The content length for the given key if one set, or {@link - * com.google.android.exoplayer2.C#LENGTH_UNSET} otherwise. - */ - long getContentLength(String key); - /** * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link * CachedContent} is added if there isn't one already with the given key. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java index e9c3379280..d527805120 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.upstream.DataSink; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ReusableBufferedOutputStream; import com.google.android.exoplayer2.util.Util; import java.io.File; @@ -30,22 +31,28 @@ import java.io.OutputStream; /** * Writes data into a cache. * - *

        If the {@link DataSpec} object used with {@link #open(DataSpec)} method call has the {@code - * length} field set to {@link C#LENGTH_UNSET} but {@link - * DataSpec#FLAG_ALLOW_CACHING_UNKNOWN_LENGTH} isn't set then {@link #write(byte[], int, int)} calls - * are ignored. + *

        If the {@link DataSpec} passed to {@link #open(DataSpec)} has the {@code length} field set to + * {@link C#LENGTH_UNSET} and {@link DataSpec#FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} set, then {@link + * #write(byte[], int, int)} calls are ignored. */ public final class CacheDataSink implements DataSink { + /** Default {@code fragmentSize} recommended for caching use cases. */ + public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024; /** Default buffer size in bytes. */ - public static final int DEFAULT_BUFFER_SIZE = 20480; + public static final int DEFAULT_BUFFER_SIZE = 20 * 1024; + + private static final long MIN_RECOMMENDED_FRAGMENT_SIZE = 2 * 1024 * 1024; + private static final String TAG = "CacheDataSink"; private final Cache cache; - private final long maxCacheFileSize; + private final long fragmentSize; private final int bufferSize; - private final boolean syncFileDescriptor; + private boolean syncFileDescriptor; + private boolean respectCacheFragmentationFlag; private DataSpec dataSpec; + private long dataSpecFragmentSize; private File file; private OutputStream outputStream; private FileOutputStream underlyingFileOutputStream; @@ -65,59 +72,71 @@ public final class CacheDataSink implements DataSink { } /** - * Constructs a CacheDataSink using the {@link #DEFAULT_BUFFER_SIZE}. + * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}. * * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for - * a {@link DataSpec} whose size exceeds this value, then the data will be fragmented into - * multiple cache files. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. */ - public CacheDataSink(Cache cache, long maxCacheFileSize) { - this(cache, maxCacheFileSize, DEFAULT_BUFFER_SIZE, true); - } - - /** - * Constructs a CacheDataSink using the {@link #DEFAULT_BUFFER_SIZE}. - * - * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for a - * {@link DataSpec} whose size exceeds this value, then the data will be fragmented into - * multiple cache files. - * @param syncFileDescriptor Whether file descriptors are sync'd when closing output streams. - */ - public CacheDataSink(Cache cache, long maxCacheFileSize, boolean syncFileDescriptor) { - this(cache, maxCacheFileSize, DEFAULT_BUFFER_SIZE, syncFileDescriptor); + public CacheDataSink(Cache cache, long fragmentSize) { + this(cache, fragmentSize, DEFAULT_BUFFER_SIZE); } /** * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for a - * {@link DataSpec} whose size exceeds this value, then the data will be fragmented into - * multiple cache files. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative * value disables buffering. */ - public CacheDataSink(Cache cache, long maxCacheFileSize, int bufferSize) { - this(cache, maxCacheFileSize, bufferSize, true); - } - - /** - * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for a - * {@link DataSpec} whose size exceeds this value, then the data will be fragmented into - * multiple cache files. - * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative - * value disables buffering. - * @param syncFileDescriptor Whether file descriptors are sync'd when closing output streams. - */ - public CacheDataSink( - Cache cache, long maxCacheFileSize, int bufferSize, boolean syncFileDescriptor) { + public CacheDataSink(Cache cache, long fragmentSize, int bufferSize) { + Assertions.checkState( + fragmentSize > 0 || fragmentSize == C.LENGTH_UNSET, + "fragmentSize must be positive or C.LENGTH_UNSET."); + if (fragmentSize != C.LENGTH_UNSET && fragmentSize < MIN_RECOMMENDED_FRAGMENT_SIZE) { + Log.w( + TAG, + "fragmentSize is below the minimum recommended value of " + + MIN_RECOMMENDED_FRAGMENT_SIZE + + ". This may cause poor cache performance."); + } this.cache = Assertions.checkNotNull(cache); - this.maxCacheFileSize = maxCacheFileSize; + this.fragmentSize = fragmentSize == C.LENGTH_UNSET ? Long.MAX_VALUE : fragmentSize; this.bufferSize = bufferSize; + syncFileDescriptor = true; + } + + /** + * Sets whether file descriptors are synced when closing output streams. + * + *

        This method is experimental, and will be renamed or removed in a future release. + * + * @param syncFileDescriptor Whether file descriptors are synced when closing output streams. + */ + public void experimental_setSyncFileDescriptor(boolean syncFileDescriptor) { this.syncFileDescriptor = syncFileDescriptor; } + /** + * Sets whether this instance respects the {@link DataSpec#FLAG_ALLOW_CACHE_FRAGMENTATION} flag. + * If set to {@code false} requests will always be fragmented. If set to {@code true} requests + * will be fragmented only if the flag is set. + * + *

        This method is experimental, and will be renamed or removed in a future release. + * + * @param respectCacheFragmentationFlag Whether to respect the {@link + * DataSpec#FLAG_ALLOW_CACHE_FRAGMENTATION} flag. + */ + public void experimental_setRespectCacheFragmentationFlag(boolean respectCacheFragmentationFlag) { + this.respectCacheFragmentationFlag = respectCacheFragmentationFlag; + } + @Override public void open(DataSpec dataSpec) throws CacheDataSinkException { if (dataSpec.length == C.LENGTH_UNSET @@ -126,6 +145,11 @@ public final class CacheDataSink implements DataSink { return; } this.dataSpec = dataSpec; + this.dataSpecFragmentSize = + !respectCacheFragmentationFlag + || dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) + ? fragmentSize + : Long.MAX_VALUE; dataSpecBytesWritten = 0; try { openNextOutputStream(); @@ -142,12 +166,12 @@ public final class CacheDataSink implements DataSink { try { int bytesWritten = 0; while (bytesWritten < length) { - if (outputStreamBytesWritten == maxCacheFileSize) { + if (outputStreamBytesWritten == dataSpecFragmentSize) { closeCurrentOutputStream(); openNextOutputStream(); } - int bytesToWrite = (int) Math.min(length - bytesWritten, - maxCacheFileSize - outputStreamBytesWritten); + int bytesToWrite = + (int) Math.min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten); outputStream.write(buffer, offset + bytesWritten, bytesToWrite); bytesWritten += bytesToWrite; outputStreamBytesWritten += bytesToWrite; @@ -171,10 +195,13 @@ 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, - maxLength); + long length = + dataSpec.length == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : Math.min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize); + file = + cache.startFile( + dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, length); underlyingFileOutputStream = new FileOutputStream(file); if (bufferSize > 0) { if (bufferedOutputStream == null) { @@ -209,7 +236,7 @@ public final class CacheDataSink implements DataSink { File fileToCommit = file; file = null; if (success) { - cache.commitFile(fileToCommit); + cache.commitFile(fileToCommit, outputStreamBytesWritten); } else { fileToCommit.delete(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java index 0b9ab66508..9540597c2e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java @@ -23,28 +23,50 @@ import com.google.android.exoplayer2.upstream.DataSink; public final class CacheDataSinkFactory implements DataSink.Factory { private final Cache cache; - private final long maxCacheFileSize; + private final long fragmentSize; private final int bufferSize; - /** - * @see CacheDataSink#CacheDataSink(Cache, long) - */ - public CacheDataSinkFactory(Cache cache, long maxCacheFileSize) { - this(cache, maxCacheFileSize, CacheDataSink.DEFAULT_BUFFER_SIZE); + private boolean syncFileDescriptor; + private boolean respectCacheFragmentationFlag; + + /** @see CacheDataSink#CacheDataSink(Cache, long) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize) { + this(cache, fragmentSize, CacheDataSink.DEFAULT_BUFFER_SIZE); + } + + /** @see CacheDataSink#CacheDataSink(Cache, long, int) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize, int bufferSize) { + this.cache = cache; + this.fragmentSize = fragmentSize; + this.bufferSize = bufferSize; } /** - * @see CacheDataSink#CacheDataSink(Cache, long, int) + * See {@link CacheDataSink#experimental_setSyncFileDescriptor(boolean)}. + * + *

        This method is experimental, and will be renamed or removed in a future release. */ - public CacheDataSinkFactory(Cache cache, long maxCacheFileSize, int bufferSize) { - this.cache = cache; - this.maxCacheFileSize = maxCacheFileSize; - this.bufferSize = bufferSize; + public CacheDataSinkFactory experimental_setSyncFileDescriptor(boolean syncFileDescriptor) { + this.syncFileDescriptor = syncFileDescriptor; + return this; + } + + /** + * See {@link CacheDataSink#experimental_setRespectCacheFragmentationFlag(boolean)}. + * + *

        This method is experimental, and will be renamed or removed in a future release. + */ + public CacheDataSinkFactory experimental_setRespectCacheFragmentationFlag( + boolean respectCacheFragmentationFlag) { + this.respectCacheFragmentationFlag = respectCacheFragmentationFlag; + return this; } @Override public DataSink createDataSink() { - return new CacheDataSink(cache, maxCacheFileSize, bufferSize); + CacheDataSink dataSink = new CacheDataSink(cache, fragmentSize, bufferSize); + dataSink.experimental_setSyncFileDescriptor(syncFileDescriptor); + dataSink.experimental_setRespectCacheFragmentationFlag(respectCacheFragmentationFlag); + return dataSink; } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index eaf72cf7fb..909bd40023 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -42,21 +42,9 @@ import java.util.Map; * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache * when possible. When data is not cached it is requested from an upstream {@link DataSource} and * written into the cache. - * - *

        By default requests whose length can not be resolved are not cached. This is to prevent - * caching of progressive live streams, which should usually not be cached. Caching of this kind of - * requests can be enabled per request with {@link DataSpec#FLAG_ALLOW_CACHING_UNKNOWN_LENGTH}. */ public final class CacheDataSource implements DataSource { - /** - * Default maximum single cache file size. - * - * @see #CacheDataSource(Cache, DataSource, int) - * @see #CacheDataSource(Cache, DataSource, int, long) - */ - public static final long DEFAULT_MAX_CACHE_FILE_SIZE = 2 * 1024 * 1024; - /** * Flags controlling the CacheDataSource's behavior. Possible flag values are {@link * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link @@ -167,7 +155,7 @@ public final class CacheDataSource implements DataSource { * @param upstream A {@link DataSource} for reading data not in the cache. */ public CacheDataSource(Cache cache, DataSource upstream) { - this(cache, upstream, 0, DEFAULT_MAX_CACHE_FILE_SIZE); + this(cache, upstream, /* flags= */ 0); } /** @@ -180,29 +168,11 @@ public final class CacheDataSource implements DataSource { * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. */ public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) { - this(cache, upstream, flags, DEFAULT_MAX_CACHE_FILE_SIZE); - } - - /** - * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for - * reading and writing the cache. The sink is configured to fragment data such that no single - * cache file is greater than maxCacheFileSize bytes. - * - * @param cache The cache. - * @param upstream A {@link DataSource} for reading data not in the cache. - * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} - * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the cached data size - * exceeds this value, then the data will be fragmented into multiple cache files. The - * finer-grained this is the finer-grained the eviction policy can be. - */ - public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags, - long maxCacheFileSize) { this( cache, upstream, new FileDataSource(), - new CacheDataSink(cache, maxCacheFileSize), + new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), flags, /* eventListener= */ null); } @@ -303,7 +273,7 @@ public final class CacheDataSource implements DataSource { if (dataSpec.length != C.LENGTH_UNSET || currentRequestIgnoresCache) { bytesRemaining = dataSpec.length; } else { - bytesRemaining = cache.getContentLength(key); + bytesRemaining = ContentMetadata.getContentLength(cache.getContentMetadata(key)); if (bytesRemaining != C.LENGTH_UNSET) { bytesRemaining -= dataSpec.position; if (bytesRemaining <= 0) { @@ -488,16 +458,12 @@ public final class CacheDataSource implements DataSource { ContentMetadataMutations mutations = new ContentMetadataMutations(); if (currentDataSpecLengthUnset && resolvedLength != C.LENGTH_UNSET) { bytesRemaining = resolvedLength; - ContentMetadataInternal.setContentLength(mutations, readPosition + bytesRemaining); + ContentMetadataMutations.setContentLength(mutations, readPosition + bytesRemaining); } if (isReadingFromUpstream()) { actualUri = currentDataSource.getUri(); boolean isRedirected = !uri.equals(actualUri); - if (isRedirected) { - ContentMetadataInternal.setRedirectedUri(mutations, actualUri); - } else { - ContentMetadataInternal.removeRedirectedUri(mutations); - } + ContentMetadataMutations.setRedirectedUri(mutations, isRedirected ? actualUri : null); } if (isWritingToCache()) { cache.applyContentMetadataMutations(key, mutations); @@ -507,14 +473,15 @@ public final class CacheDataSource implements DataSource { private void setNoBytesRemainingAndMaybeStoreLength() throws IOException { bytesRemaining = 0; if (isWritingToCache()) { - cache.setContentLength(key, readPosition); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, readPosition); + cache.applyContentMetadataMutations(key, mutations); } } private static Uri getRedirectedUriOrDefault(Cache cache, String key, Uri defaultUri) { - ContentMetadata contentMetadata = cache.getContentMetadata(key); - Uri redirectedUri = ContentMetadataInternal.getRedirectedUri(contentMetadata); - return redirectedUri == null ? defaultUri : redirectedUri; + Uri redirectedUri = ContentMetadata.getRedirectedUri(cache.getContentMetadata(key)); + return redirectedUri != null ? redirectedUri : defaultUri; } private static boolean isCausedByPositionOutOfRange(IOException e) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java index d9f9470fe3..9675aa1762 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java @@ -46,20 +46,11 @@ public final class CacheDataSourceFactory implements DataSource.Factory { /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */ public CacheDataSourceFactory( Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) { - this(cache, upstreamFactory, flags, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE); - } - - /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int, long) */ - public CacheDataSourceFactory( - Cache cache, - DataSource.Factory upstreamFactory, - @CacheDataSource.Flags int flags, - long maxCacheFileSize) { this( cache, upstreamFactory, new FileDataSourceFactory(), - new CacheDataSinkFactory(cache, maxCacheFileSize), + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), flags, /* eventListener= */ null); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java index 8944b45033..dbec4b78fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream.cache; +import com.google.android.exoplayer2.C; + /** * Evicts data from a {@link Cache}. Implementations should call {@link Cache#removeSpan(CacheSpan)} * to evict cache entries based on their eviction policies. @@ -32,8 +34,7 @@ public interface CacheEvictor extends Cache.Listener { * @param cache The source of the event. * @param key The key being written. * @param position The starting position of the data being written. - * @param maxLength The maximum length of the data being written. + * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. */ - void onStartFile(Cache cache, String key, long position, long maxLength); - + void onStartFile(Cache cache, String key, long position, long length); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index fd4937ef86..9714df6ad0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -84,7 +84,10 @@ public final class CacheUtil { CachingCounters counters) { String key = buildCacheKey(dataSpec, cacheKeyFactory); long start = dataSpec.absoluteStreamPosition; - long left = dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : cache.getContentLength(key); + long left = + dataSpec.length != C.LENGTH_UNSET + ? dataSpec.length + : ContentMetadata.getContentLength(cache.getContentMetadata(key)); counters.contentLength = left; counters.alreadyCachedBytes = 0; counters.newlyCachedBytes = 0; @@ -188,7 +191,10 @@ public final class CacheUtil { String key = buildCacheKey(dataSpec, cacheKeyFactory); long start = dataSpec.absoluteStreamPosition; - long left = dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : cache.getContentLength(key); + long left = + dataSpec.length != C.LENGTH_UNSET + ? dataSpec.length + : ContentMetadata.getContentLength(cache.getContentMetadata(key)); while (left != 0) { throwExceptionIfInterruptedOrCancelled(isCanceled); long blockLength = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 4d15de5932..5494454d54 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -55,7 +55,7 @@ import java.util.TreeSet; if (version < VERSION_METADATA_INTRODUCED) { long length = input.readLong(); ContentMetadataMutations mutations = new ContentMetadataMutations(); - ContentMetadataInternal.setContentLength(mutations, length); + ContentMetadataMutations.setContentLength(mutations, length); cachedContent.applyMetadataMutations(mutations); } else { cachedContent.metadata = DefaultContentMetadata.readFromStream(input); @@ -216,7 +216,7 @@ import java.util.TreeSet; int result = id; result = 31 * result + key.hashCode(); if (version < VERSION_METADATA_INTRODUCED) { - long length = ContentMetadataInternal.getContentLength(metadata); + long length = ContentMetadata.getContentLength(metadata); result = 31 * result + (int) (length ^ (length >>> 32)); } else { result = 31 * result + metadata.hashCode(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index 19160c73d4..a744917230 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.upstream.cache; import android.support.annotation.VisibleForTesting; import android.util.SparseArray; +import android.util.SparseBooleanArray; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.AtomicFile; @@ -42,6 +43,7 @@ import javax.crypto.CipherOutputStream; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** Maintains the index of cached content. */ /* package */ class CachedContentIndex { @@ -53,7 +55,30 @@ import javax.crypto.spec.SecretKeySpec; private static final int FLAG_ENCRYPTED_INDEX = 1; private final HashMap keyToContent; - private final SparseArray idToKey; + /** + * Maps assigned ids to their corresponding keys. Also contains (id -> null) entries for ids that + * have been removed from the index since it was last stored. This prevents reuse of these ids, + * which is necessary to avoid clashes that could otherwise occur as a result of the sequence: + * + *

        [1] (key1, id1) is removed from the in-memory index ... the index is not stored to disk ... + * [2] id1 is reused for a different key2 ... the index is not stored to disk ... [3] A file for + * key2 is partially written using a path corresponding to id1 ... the process is killed before + * the index is stored to disk ... [4] The index is read from disk, causing the partially written + * file to be incorrectly associated to key1 + * + *

        By avoiding id reuse in step [2], a new id2 will be used instead. Step [4] will then delete + * the partially written file because the index does not contain an entry for id2. + * + *

        When the index is next stored (id -> null) entries are removed, making the ids eligible for + * reuse. + */ + private final SparseArray<@NullableType String> idToKey; + /** + * Tracks ids for which (id -> null) entries are present in idToKey, so that they can be removed + * efficiently when the index is next stored. + */ + private final SparseBooleanArray removedIds; + private final AtomicFile atomicFile; private final Cipher cipher; private final SecretKeySpec secretKeySpec; @@ -105,6 +130,7 @@ import javax.crypto.spec.SecretKeySpec; } keyToContent = new HashMap<>(); idToKey = new SparseArray<>(); + removedIds = new SparseBooleanArray(); atomicFile = new AtomicFile(new File(cacheDir, FILE_NAME)); } @@ -125,6 +151,12 @@ import javax.crypto.spec.SecretKeySpec; } writeFile(); changed = false; + // Make ids that were removed since the index was last stored eligible for re-use. + int removedIdCount = removedIds.size(); + for (int i = 0; i < removedIdCount; i++) { + idToKey.remove(removedIds.keyAt(i)); + } + removedIds.clear(); } /** @@ -169,8 +201,11 @@ import javax.crypto.spec.SecretKeySpec; CachedContent cachedContent = keyToContent.get(key); if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { keyToContent.remove(key); - idToKey.remove(cachedContent.id); changed = true; + // Keep an entry in idToKey to stop the id from being reused until the index is next stored. + idToKey.put(cachedContent.id, /* value= */ null); + // Track that the entry should be removed from idToKey when the index is next stored. + removedIds.put(cachedContent.id, /* value= */ true); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java index aacd11f915..f0075343ad 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java @@ -15,44 +15,73 @@ */ package com.google.android.exoplayer2.upstream.cache; +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; + /** * Interface for an immutable snapshot of keyed metadata. - * - *

        Internal metadata names are prefixed with {@value #INTERNAL_METADATA_NAME_PREFIX}. Custom - * metadata names should avoid this prefix to prevent clashes. */ public interface ContentMetadata { - /** Prefix of internal metadata names. */ - String INTERNAL_METADATA_NAME_PREFIX = "exo_"; + /** + * Prefix for custom metadata keys. Applications can use keys starting with this prefix without + * any risk of their keys colliding with ones defined by the ExoPlayer library. + */ + @SuppressWarnings("unused") + String KEY_CUSTOM_PREFIX = "custom_"; + /** Key for redirected uri (type: String). */ + String KEY_REDIRECTED_URI = "exo_redir"; + /** Key for content length in bytes (type: long). */ + String KEY_CONTENT_LENGTH = "exo_len"; /** * Returns a metadata value. * - * @param name Name of the metadata to be returned. + * @param key Key of the metadata to be returned. * @param defaultValue Value to return if the metadata doesn't exist. * @return The metadata value. */ - byte[] get(String name, byte[] defaultValue); + @Nullable + byte[] get(String key, @Nullable byte[] defaultValue); /** * Returns a metadata value. * - * @param name Name of the metadata to be returned. + * @param key Key of the metadata to be returned. * @param defaultValue Value to return if the metadata doesn't exist. * @return The metadata value. */ - String get(String name, String defaultValue); + @Nullable + String get(String key, @Nullable String defaultValue); /** * Returns a metadata value. * - * @param name Name of the metadata to be returned. + * @param key Key of the metadata to be returned. * @param defaultValue Value to return if the metadata doesn't exist. * @return The metadata value. */ - long get(String name, long defaultValue); + long get(String key, long defaultValue); /** Returns whether the metadata is available. */ - boolean contains(String name); + boolean contains(String key); + + /** + * Returns the value stored under {@link #KEY_CONTENT_LENGTH}, or {@link C#LENGTH_UNSET} if not + * set. + */ + static long getContentLength(ContentMetadata contentMetadata) { + return contentMetadata.get(KEY_CONTENT_LENGTH, C.LENGTH_UNSET); + } + + /** + * Returns the value stored under {@link #KEY_REDIRECTED_URI} as a {@link Uri}, or {code null} if + * not set. + */ + @Nullable + static Uri getRedirectedUri(ContentMetadata contentMetadata) { + String redirectedUri = contentMetadata.get(KEY_REDIRECTED_URI, (String) null); + return redirectedUri == null ? null : Uri.parse(redirectedUri); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataInternal.java deleted file mode 100644 index 0065018260..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataInternal.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2018 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.net.Uri; -import android.support.annotation.Nullable; -import com.google.android.exoplayer2.C; - -/** Helper classes to easily access and modify internal metadata values. */ -/* package */ final class ContentMetadataInternal { - - private static final String PREFIX = ContentMetadata.INTERNAL_METADATA_NAME_PREFIX; - private static final String METADATA_NAME_REDIRECTED_URI = PREFIX + "redir"; - private static final String METADATA_NAME_CONTENT_LENGTH = PREFIX + "len"; - - /** Returns the content length metadata, or {@link C#LENGTH_UNSET} if not set. */ - public static long getContentLength(ContentMetadata contentMetadata) { - return contentMetadata.get(METADATA_NAME_CONTENT_LENGTH, C.LENGTH_UNSET); - } - - /** Adds a mutation to set content length metadata value. */ - public static void setContentLength(ContentMetadataMutations mutations, long length) { - mutations.set(METADATA_NAME_CONTENT_LENGTH, length); - } - - /** Adds a mutation to remove content length metadata value. */ - public static void removeContentLength(ContentMetadataMutations mutations) { - mutations.remove(METADATA_NAME_CONTENT_LENGTH); - } - - /** Returns the redirected uri metadata, or {@code null} if not set. */ - public @Nullable static Uri getRedirectedUri(ContentMetadata contentMetadata) { - String redirectedUri = contentMetadata.get(METADATA_NAME_REDIRECTED_URI, (String) null); - return redirectedUri == null ? null : Uri.parse(redirectedUri); - } - - /** - * Adds a mutation to set redirected uri metadata value. Passing {@code null} as {@code uri} isn't - * allowed. - */ - public static void setRedirectedUri(ContentMetadataMutations mutations, Uri uri) { - mutations.set(METADATA_NAME_REDIRECTED_URI, uri.toString()); - } - - /** Adds a mutation to remove redirected uri metadata value. */ - public static void removeRedirectedUri(ContentMetadataMutations mutations) { - mutations.remove(METADATA_NAME_REDIRECTED_URI); - } - - private ContentMetadataInternal() { - // Prevent instantiation. - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java index 70154b0308..fb3f6e362d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.upstream.cache; +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import java.util.ArrayList; import java.util.Arrays; @@ -30,6 +33,36 @@ import java.util.Map.Entry; */ public class ContentMetadataMutations { + /** + * Adds a mutation to set the {@link ContentMetadata#KEY_CONTENT_LENGTH} value, or to remove any + * existing value if {@link C#LENGTH_UNSET} is passed. + * + * @param mutations The mutations to modify. + * @param length The length value, or {@link C#LENGTH_UNSET} to remove any existing entry. + * @return The mutations instance, for convenience. + */ + public static ContentMetadataMutations setContentLength( + ContentMetadataMutations mutations, long length) { + return mutations.set(ContentMetadata.KEY_CONTENT_LENGTH, length); + } + + /** + * Adds a mutation to set the {@link ContentMetadata#KEY_REDIRECTED_URI} value, or to remove any + * existing entry if {@code null} is passed. + * + * @param mutations The mutations to modify. + * @param uri The {@link Uri} value, or {@code null} to remove any existing entry. + * @return The mutations instance, for convenience. + */ + public static ContentMetadataMutations setRedirectedUri( + ContentMetadataMutations mutations, @Nullable Uri uri) { + if (uri == null) { + return mutations.remove(ContentMetadata.KEY_REDIRECTED_URI); + } else { + return mutations.set(ContentMetadata.KEY_REDIRECTED_URI, uri.toString()); + } + } + private final Map editedValues; private final List removedValues; @@ -45,7 +78,7 @@ public class ContentMetadataMutations { * * @param name The name of the metadata value. * @param value The value to be set. - * @return This Editor instance, for convenience. + * @return This instance, for convenience. */ public ContentMetadataMutations set(String name, String value) { return checkAndSet(name, value); @@ -56,7 +89,7 @@ public class ContentMetadataMutations { * * @param name The name of the metadata value. * @param value The value to be set. - * @return This Editor instance, for convenience. + * @return This instance, for convenience. */ public ContentMetadataMutations set(String name, long value) { return checkAndSet(name, value); @@ -68,7 +101,7 @@ public class ContentMetadataMutations { * * @param name The name of the metadata value. * @param value The value to be set. - * @return This Editor instance, for convenience. + * @return This instance, for convenience. */ public ContentMetadataMutations set(String name, byte[] value) { return checkAndSet(name, Arrays.copyOf(value, value.length)); @@ -78,7 +111,7 @@ public class ContentMetadataMutations { * Adds a mutation to remove a metadata value. * * @param name The name of the metadata value. - * @return This Editor instance, for convenience. + * @return This instance, for convenience. */ public ContentMetadataMutations remove(String name) { removedValues.add(name); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java index e16ff5483a..843dd19444 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java @@ -64,6 +64,10 @@ public final class DefaultContentMetadata implements ContentMetadata { private final Map metadata; + public DefaultContentMetadata() { + this(Collections.emptyMap()); + } + private DefaultContentMetadata(Map metadata) { this.metadata = Collections.unmodifiableMap(metadata); } @@ -74,7 +78,7 @@ public final class DefaultContentMetadata implements ContentMetadata { */ public DefaultContentMetadata copyWithMutationsApplied(ContentMetadataMutations mutations) { Map mutatedMetadata = applyMutations(metadata, mutations); - if (isMetadataEqual(mutatedMetadata)) { + if (isMetadataEqual(metadata, mutatedMetadata)) { return this; } return new DefaultContentMetadata(mutatedMetadata); @@ -97,7 +101,8 @@ public final class DefaultContentMetadata implements ContentMetadata { } @Override - public final byte[] get(String name, byte[] defaultValue) { + @Nullable + public final byte[] get(String name, @Nullable byte[] defaultValue) { if (metadata.containsKey(name)) { byte[] bytes = metadata.get(name); return Arrays.copyOf(bytes, bytes.length); @@ -107,7 +112,8 @@ public final class DefaultContentMetadata implements ContentMetadata { } @Override - public final String get(String name, String defaultValue) { + @Nullable + public final String get(String name, @Nullable String defaultValue) { if (metadata.containsKey(name)) { byte[] bytes = metadata.get(name); return new String(bytes, Charset.forName(C.UTF8_NAME)); @@ -139,21 +145,7 @@ public final class DefaultContentMetadata implements ContentMetadata { if (o == null || getClass() != o.getClass()) { return false; } - return isMetadataEqual(((DefaultContentMetadata) o).metadata); - } - - private boolean isMetadataEqual(Map otherMetadata) { - if (metadata.size() != otherMetadata.size()) { - return false; - } - for (Entry entry : metadata.entrySet()) { - byte[] value = entry.getValue(); - byte[] otherValue = otherMetadata.get(entry.getKey()); - if (!Arrays.equals(value, otherValue)) { - return false; - } - } - return true; + return isMetadataEqual(metadata, ((DefaultContentMetadata) o).metadata); } @Override @@ -168,6 +160,20 @@ public final class DefaultContentMetadata implements ContentMetadata { return hashCode; } + private static boolean isMetadataEqual(Map first, Map second) { + if (first.size() != second.size()) { + return false; + } + for (Entry entry : first.entrySet()) { + byte[] value = entry.getValue(); + byte[] otherValue = second.get(entry.getKey()); + if (!Arrays.equals(value, otherValue)) { + return false; + } + } + return true; + } + private static Map applyMutations( Map otherMetadata, ContentMetadataMutations mutations) { HashMap metadata = new HashMap<>(otherMetadata); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java index 79d23dd1b0..aa40c1d2fd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.cache; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.util.Comparator; import java.util.TreeSet; @@ -40,8 +41,10 @@ public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor, Compar } @Override - public void onStartFile(Cache cache, String key, long position, long maxLength) { - evictCache(cache, maxLength); + public void onStartFile(Cache cache, String key, long position, long length) { + if (length != C.LENGTH_UNSET) { + evictCache(cache, length); + } } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 1fd6dc63bc..7f9bdde5c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -146,13 +146,16 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void release() throws CacheException { + public synchronized void release() { if (released) { return; } listeners.clear(); + removeStaleSpans(); try { - removeStaleSpansAndCachedContents(); + index.store(); + } catch (CacheException e) { + Log.e(TAG, "Storing index file failed", e); } finally { unlockFolder(cacheDir); released = true; @@ -256,8 +259,7 @@ public final class SimpleCache implements Cache { } @Override - public synchronized File startFile(String key, long position, long maxLength) - throws CacheException { + public synchronized File startFile(String key, long position, long length) throws CacheException { Assertions.checkState(!released); CachedContent cachedContent = index.get(key); Assertions.checkNotNull(cachedContent); @@ -265,34 +267,32 @@ public final class SimpleCache implements Cache { if (!cacheDir.exists()) { // For some reason the cache directory doesn't exist. Make a best effort to create it. cacheDir.mkdirs(); - removeStaleSpansAndCachedContents(); + removeStaleSpans(); } - evictor.onStartFile(this, key, position, maxLength); + evictor.onStartFile(this, key, position, length); return SimpleCacheSpan.getCacheFile( cacheDir, cachedContent.id, position, System.currentTimeMillis()); } @Override - public synchronized void commitFile(File file) throws CacheException { + public synchronized void commitFile(File file, long length) throws CacheException { Assertions.checkState(!released); - SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, index); + if (!file.exists()) { + return; + } + if (length == 0) { + file.delete(); + return; + } + SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, length, index); Assertions.checkState(span != null); CachedContent cachedContent = index.get(span.key); Assertions.checkNotNull(cachedContent); Assertions.checkState(cachedContent.isLocked()); - // If the file doesn't exist, don't add it to the in-memory representation. - if (!file.exists()) { - return; - } - // If the file has length 0, delete it and don't add it to the in-memory representation. - if (file.length() == 0) { - file.delete(); - return; - } // Check if the span conflicts with the set content length - long length = ContentMetadataInternal.getContentLength(cachedContent.getMetadata()); - if (length != C.LENGTH_UNSET) { - Assertions.checkState((span.position + span.length) <= length); + long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata()); + if (contentLength != C.LENGTH_UNSET) { + Assertions.checkState((span.position + span.length) <= contentLength); } addSpan(span); index.store(); @@ -311,9 +311,9 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void removeSpan(CacheSpan span) throws CacheException { + public synchronized void removeSpan(CacheSpan span) { Assertions.checkState(!released); - removeSpan(span, true); + removeSpanInternal(span); } @Override @@ -330,18 +330,6 @@ public final class SimpleCache implements Cache { return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; } - @Override - public synchronized void setContentLength(String key, long length) throws CacheException { - ContentMetadataMutations mutations = new ContentMetadataMutations(); - ContentMetadataInternal.setContentLength(mutations, length); - applyContentMetadataMutations(key, mutations); - } - - @Override - public synchronized long getContentLength(String key) { - return ContentMetadataInternal.getContentLength(getContentMetadata(key)); - } - @Override public synchronized void applyContentMetadataMutations( String key, ContentMetadataMutations mutations) throws CacheException { @@ -379,7 +367,7 @@ public final class SimpleCache implements Cache { if (span.isCached && !span.file.exists()) { // The file has been deleted from under us. It's likely that other files will have been // deleted too, so scan the whole in-memory representation. - removeStaleSpansAndCachedContents(); + removeStaleSpans(); continue; } return span; @@ -394,25 +382,9 @@ public final class SimpleCache implements Cache { } index.load(); - - File[] files = cacheDir.listFiles(); - if (files == null) { - return; - } - for (File file : files) { - if (file.getName().equals(CachedContentIndex.FILE_NAME)) { - continue; - } - SimpleCacheSpan span = - file.length() > 0 ? SimpleCacheSpan.createCacheEntry(file, index) : null; - if (span != null) { - addSpan(span); - } else { - file.delete(); - } - } - + loadDirectory(cacheDir, /* isRootDirectory= */ true); index.removeEmpty(); + try { index.store(); } catch (CacheException e) { @@ -420,6 +392,38 @@ public final class SimpleCache implements Cache { } } + private void loadDirectory(File directory, boolean isRootDirectory) { + File[] files = directory.listFiles(); + if (files == null) { + // Not a directory. + return; + } + if (!isRootDirectory && files.length == 0) { + // Empty non-root directory. + directory.delete(); + return; + } + for (File file : files) { + String fileName = file.getName(); + if (isRootDirectory && fileName.indexOf('.') == -1) { + loadDirectory(file, /* isRootDirectory= */ false); + } else { + if (isRootDirectory && CachedContentIndex.FILE_NAME.equals(fileName)) { + // Skip the (expected) index file in the root directory. + continue; + } + long fileLength = file.length(); + SimpleCacheSpan span = + fileLength > 0 ? SimpleCacheSpan.createCacheEntry(file, fileLength, index) : null; + if (span != null) { + addSpan(span); + } else { + file.delete(); + } + } + } + } + /** * Adds a cached span to the in-memory representation. * @@ -431,27 +435,21 @@ public final class SimpleCache implements Cache { notifySpanAdded(span); } - private void removeSpan(CacheSpan span, boolean removeEmptyCachedContent) throws CacheException { + private void removeSpanInternal(CacheSpan span) { CachedContent cachedContent = index.get(span.key); if (cachedContent == null || !cachedContent.removeSpan(span)) { return; } totalSpace -= span.length; - try { - if (removeEmptyCachedContent) { - index.maybeRemove(cachedContent.key); - index.store(); - } - } finally { - notifySpanRemoved(span); - } + index.maybeRemove(cachedContent.key); + notifySpanRemoved(span); } /** * Scans all of the cached spans in the in-memory representation, removing any for which files no * longer exist. */ - private void removeStaleSpansAndCachedContents() throws CacheException { + private void removeStaleSpans() { ArrayList spansToBeRemoved = new ArrayList<>(); for (CachedContent cachedContent : index.getAll()) { for (CacheSpan span : cachedContent.getSpans()) { @@ -461,11 +459,8 @@ public final class SimpleCache implements Cache { } } for (int i = 0; i < spansToBeRemoved.size(); i++) { - // Remove span but not CachedContent to prevent multiple index.store() calls. - removeSpan(spansToBeRemoved.get(i), false); + removeSpanInternal(spansToBeRemoved.get(i)); } - index.removeEmpty(); - index.store(); } private void notifySpanRemoved(CacheSpan span) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java index 82e9f03f4f..dfa553ffe4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -82,16 +82,22 @@ import java.util.regex.Pattern; return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); } + /* + * Note: {@code fileLength} is equivalent to {@code file.length()}, but passing it as an explicit + * argument can reduce the number of calls to this method if the calling code already knows the + * file length. This is preferable because calling {@code file.length()} can be expensive. See: + * https://github.com/google/ExoPlayer/issues/4253#issuecomment-451593889. + */ /** * Creates a cache span from an underlying cache file. Upgrades the file if necessary. * * @param file The cache file. - * @param index Cached content index. + * @param length The length of the cache file in bytes. * @return The span, or null if the file name is not correctly formatted, or if the id is not * present in the content index. */ @Nullable - public static SimpleCacheSpan createCacheEntry(File file, CachedContentIndex index) { + public static SimpleCacheSpan createCacheEntry(File file, long length, CachedContentIndex index) { String name = file.getName(); if (!name.endsWith(SUFFIX)) { file = upgradeFile(file, index); @@ -105,11 +111,12 @@ import java.util.regex.Pattern; if (!matcher.matches()) { return null; } - long length = file.length(); int id = Integer.parseInt(matcher.group(1)); String key = index.getKeyForId(id); - return key == null ? null : new SimpleCacheSpan(key, Long.parseLong(matcher.group(2)), length, - Long.parseLong(matcher.group(3)), file); + return key == null + ? null + : new SimpleCacheSpan( + key, Long.parseLong(matcher.group(2)), length, Long.parseLong(matcher.group(3)), file); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java b/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java index 4bdee5ceea..2466d5a049 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java @@ -29,7 +29,7 @@ import java.io.OutputStream; * has successfully completed. * *

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

        Atomic file does not confer any file locking semantics. Do not use this class when the file diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index e506ae1b19..c5ce93a239 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -92,6 +92,7 @@ public final class MimeTypes { public static final String APPLICATION_EMSG = BASE_TYPE_APPLICATION + "/x-emsg"; public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs"; public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif"; + public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy"; private static final ArrayList customMimeTypes = new ArrayList<>(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 7bea5de8ba..1e1153d367 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1481,11 +1481,12 @@ public final class Util { } /** - * Maps a {@link C} {@code TRACK_TYPE_*} constant to the corresponding {@link C} - * {@code DEFAULT_*_BUFFER_SIZE} constant. + * Maps a {@link C} {@code TRACK_TYPE_*} constant to the corresponding {@link C} {@code + * DEFAULT_*_BUFFER_SIZE} constant. * * @param trackType The track type. * @return The corresponding default buffer size in bytes. + * @throws IllegalArgumentException If the track type is an unrecognized or custom track type. */ public static int getDefaultBufferSize(int trackType) { switch (trackType) { @@ -1501,8 +1502,10 @@ public final class Util { return C.DEFAULT_METADATA_BUFFER_SIZE; case C.TRACK_TYPE_CAMERA_MOTION: return C.DEFAULT_CAMERA_MOTION_BUFFER_SIZE; + case C.TRACK_TYPE_NONE: + return 0; default: - throw new IllegalStateException(); + throw new IllegalArgumentException(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 67586fe672..6943fea7b6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -98,7 +98,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final EventDispatcher eventDispatcher; private final long allowedJoiningTimeMs; private final int maxDroppedFramesToNotify; - private final boolean deviceNeedsAutoFrcWorkaround; + private final boolean deviceNeedsNoPostProcessWorkaround; private final long[] pendingOutputStreamOffsetsUs; private final long[] pendingOutputStreamSwitchTimesUs; @@ -226,7 +226,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { this.context = context.getApplicationContext(); frameReleaseTimeHelper = new VideoFrameReleaseTimeHelper(this.context); eventDispatcher = new EventDispatcher(eventHandler, eventListener); - deviceNeedsAutoFrcWorkaround = deviceNeedsAutoFrcWorkaround(); + deviceNeedsNoPostProcessWorkaround = deviceNeedsNoPostProcessWorkaround(); pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; outputStreamOffsetUs = C.TIME_UNSET; @@ -375,7 +375,6 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { try { super.onDisabled(); } finally { - decoderCounters.ensureUpdated(); eventDispatcher.disabled(decoderCounters); } } @@ -484,7 +483,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { format, codecMaxValues, codecOperatingRate, - deviceNeedsAutoFrcWorkaround, + deviceNeedsNoPostProcessWorkaround, tunnelingAudioSessionId); if (surface == null) { Assertions.checkState(shouldUseDummySurface(codecInfo)); @@ -868,7 +867,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { // We dropped some buffers to catch up, so update the decoder counters and flush the codec, // which releases all pending buffers buffers including the current output buffer. updateDroppedBufferCounters(buffersInCodecCount + droppedSourceBufferCount); - flushOrReinitCodec(); + flushOrReinitializeCodec(); return true; } @@ -1036,8 +1035,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { * @param codecMaxValues Codec max values that should be used when configuring the decoder. * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if * no codec operating rate should be set. - * @param deviceNeedsAutoFrcWorkaround Whether the device is known to enable frame-rate conversion - * logic that negatively impacts ExoPlayer. + * @param deviceNeedsNoPostProcessWorkaround Whether the device is known to do post processing by + * default that isn't compatible with ExoPlayer. * @param tunnelingAudioSessionId The audio session id to use for tunneling, or {@link * C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. * @return The framework {@link MediaFormat} that should be used to configure the decoder. @@ -1047,7 +1046,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { Format format, CodecMaxValues codecMaxValues, float codecOperatingRate, - boolean deviceNeedsAutoFrcWorkaround, + boolean deviceNeedsNoPostProcessWorkaround, int tunnelingAudioSessionId) { MediaFormat mediaFormat = new MediaFormat(); // Set format parameters that should always be set. @@ -1071,7 +1070,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { mediaFormat.setFloat(MediaFormat.KEY_OPERATING_RATE, codecOperatingRate); } } - if (deviceNeedsAutoFrcWorkaround) { + if (deviceNeedsNoPostProcessWorkaround) { + mediaFormat.setInteger("no-post-process", 1); mediaFormat.setInteger("auto-frc", 0); } if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { @@ -1095,6 +1095,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { throws DecoderQueryException { int maxWidth = format.width; int maxHeight = format.height; + if (codecNeedsMaxVideoSizeResetWorkaround(codecInfo.name)) { + maxWidth = Math.max(maxWidth, 1920); + maxHeight = Math.max(maxHeight, 1089); + } int maxInputSize = getMaxInputSize(codecInfo, format); if (streamFormats.length == 1) { // The single entry in streamFormats must correspond to the format for which the codec is @@ -1265,21 +1269,33 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } /** - * Returns whether the device is known to enable frame-rate conversion logic that negatively - * impacts ExoPlayer. - *

        - * If true is returned then we explicitly disable the feature. + * Returns whether the device is known to do post processing by default that isn't compatible with + * ExoPlayer. * - * @return True if the device is known to enable frame-rate conversion logic that negatively - * impacts ExoPlayer. False otherwise. + * @return Whether the device is known to do post processing by default that isn't compatible with + * ExoPlayer. */ - private static boolean deviceNeedsAutoFrcWorkaround() { - // nVidia Shield prior to M tries to adjust the playback rate to better map the frame-rate of + private static boolean deviceNeedsNoPostProcessWorkaround() { + // Nvidia devices prior to M try to adjust the playback rate to better map the frame-rate of // content to the refresh rate of the display. For example playback of 23.976fps content is // adjusted to play at 1.001x speed when the output display is 60Hz. Unfortunately the // implementation causes ExoPlayer's reported playback position to drift out of sync. Captions - // also lose sync [Internal: b/26453592]. - return Util.SDK_INT <= 22 && "foster".equals(Util.DEVICE) && "NVIDIA".equals(Util.MANUFACTURER); + // also lose sync [Internal: b/26453592]. Even after M, the devices may apply post processing + // operations that can modify frame output timestamps, which is incompatible with ExoPlayer's + // logic for skipping decode-only frames. + return "NVIDIA".equals(Util.MANUFACTURER); + } + + /** + * Returns whether the codec is known to have problems with the configuration for interlaced + * content and needs minimum values for the maximum video size to force reset the configuration. + * + *

        See https://github.com/google/ExoPlayer/issues/5003. + * + * @param name The name of the codec. + */ + private static boolean codecNeedsMaxVideoSizeResetWorkaround(String name) { + return "OMX.amlogic.avc.decoder.awesome".equals(name) && Util.SDK_INT <= 25; } /* @@ -1305,163 +1321,173 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { * incorrectly. */ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { - if (Util.SDK_INT >= 27 || name.startsWith("OMX.google")) { - // Devices running API level 27 or later should also be unaffected. Google OMX decoders are - // not known to have this issue on any API level. + if (name.startsWith("OMX.google")) { + // Google OMX decoders are not known to have this issue on any API level. return false; } - // Work around: - // https://github.com/google/ExoPlayer/issues/3236, - // https://github.com/google/ExoPlayer/issues/3355, - // https://github.com/google/ExoPlayer/issues/3439, - // https://github.com/google/ExoPlayer/issues/3724, - // https://github.com/google/ExoPlayer/issues/3835, - // https://github.com/google/ExoPlayer/issues/4006, - // https://github.com/google/ExoPlayer/issues/4084, - // https://github.com/google/ExoPlayer/issues/4104, - // https://github.com/google/ExoPlayer/issues/4134, - // https://github.com/google/ExoPlayer/issues/4315, - // https://github.com/google/ExoPlayer/issues/4419, - // https://github.com/google/ExoPlayer/issues/4460, - // https://github.com/google/ExoPlayer/issues/4468. synchronized (MediaCodecVideoRenderer.class) { if (!evaluatedDeviceNeedsSetOutputSurfaceWorkaround) { - switch (Util.DEVICE) { - case "1601": - case "1713": - case "1714": - case "A10-70F": - case "A1601": - case "A2016a40": - case "A7000-a": - case "A7000plus": - case "A7010a48": - case "A7020a48": - case "AquaPowerM": - case "ASUS_X00AD_2": - case "Aura_Note_2": - case "BLACK-1X": - case "BRAVIA_ATV2": - case "C1": - case "ComioS1": - case "CP8676_I02": - case "CPH1609": - case "CPY83_I00": - case "cv1": - case "cv3": - case "deb": - case "E5643": - case "ELUGA_A3_Pro": - case "ELUGA_Note": - case "ELUGA_Prim": - case "ELUGA_Ray_X": - case "EverStar_S": - case "F3111": - case "F3113": - case "F3116": - case "F3211": - case "F3213": - case "F3215": - case "F3311": - case "flo": - case "GiONEE_CBL7513": - case "GiONEE_GBL7319": - case "GIONEE_GBL7360": - case "GIONEE_SWW1609": - case "GIONEE_SWW1627": - case "GIONEE_SWW1631": - case "GIONEE_WBL5708": - case "GIONEE_WBL7365": - case "GIONEE_WBL7519": - case "griffin": - case "htc_e56ml_dtul": - case "hwALE-H": - case "HWBLN-H": - case "HWCAM-H": - case "HWVNS-H": - case "i9031": - case "iball8735_9806": - case "Infinix-X572": - case "iris60": - case "itel_S41": - case "j2xlteins": - case "JGZ": - case "K50a40": - case "kate": - case "le_x6": - case "LS-5017": - case "M5c": - case "manning": - case "marino_f": - case "MEIZU_M5": - case "mh": - case "mido": - case "MX6": - case "namath": - case "nicklaus_f": - case "NX541J": - case "NX573J": - case "OnePlus5T": - case "p212": - case "P681": - case "P85": - case "panell_d": - case "panell_dl": - case "panell_ds": - case "panell_dt": - case "PB2-670M": - case "PGN528": - case "PGN610": - case "PGN611": - case "Phantom6": - case "Pixi4-7_3G": - case "Pixi5-10_4G": - case "PLE": - case "PRO7S": - case "Q350": - case "Q4260": - case "Q427": - case "Q4310": - case "Q5": - case "QM16XE_U": - case "QX1": - case "santoni": - case "Slate_Pro": - case "SVP-DTV15": - case "s905x018": - case "taido_row": - case "TB3-730F": - case "TB3-730X": - case "TB3-850F": - case "TB3-850M": - case "tcl_eu": - case "V1": - case "V23GB": - case "V5": - case "vernee_M5": - case "watson": - case "whyred": - case "woods_f": - case "woods_fn": - case "X3_HK": - case "XE2X": - case "XT1663": - case "Z12_PRO": - case "Z80": - deviceNeedsSetOutputSurfaceWorkaround = true; - break; - default: - // Do nothing. - break; - } - switch (Util.MODEL) { - case "AFTA": - case "AFTN": - deviceNeedsSetOutputSurfaceWorkaround = true; - break; - default: - // Do nothing. - break; + if (Util.SDK_INT <= 27 && "dangal".equals(Util.DEVICE)) { + // Dangal is affected on API level 27: https://github.com/google/ExoPlayer/issues/5169. + deviceNeedsSetOutputSurfaceWorkaround = true; + } else if (Util.SDK_INT >= 27) { + // In general, devices running API level 27 or later should be unaffected. Do nothing. + } else { + // Enable the workaround on a per-device basis. Works around: + // https://github.com/google/ExoPlayer/issues/3236, + // https://github.com/google/ExoPlayer/issues/3355, + // https://github.com/google/ExoPlayer/issues/3439, + // https://github.com/google/ExoPlayer/issues/3724, + // https://github.com/google/ExoPlayer/issues/3835, + // https://github.com/google/ExoPlayer/issues/4006, + // https://github.com/google/ExoPlayer/issues/4084, + // https://github.com/google/ExoPlayer/issues/4104, + // https://github.com/google/ExoPlayer/issues/4134, + // https://github.com/google/ExoPlayer/issues/4315, + // https://github.com/google/ExoPlayer/issues/4419, + // https://github.com/google/ExoPlayer/issues/4460, + // https://github.com/google/ExoPlayer/issues/4468, + // https://github.com/google/ExoPlayer/issues/5312. + switch (Util.DEVICE) { + case "1601": + case "1713": + case "1714": + case "A10-70F": + case "A1601": + case "A2016a40": + case "A7000-a": + case "A7000plus": + case "A7010a48": + case "A7020a48": + case "AquaPowerM": + case "ASUS_X00AD_2": + case "Aura_Note_2": + case "BLACK-1X": + case "BRAVIA_ATV2": + case "BRAVIA_ATV3_4K": + case "C1": + case "ComioS1": + case "CP8676_I02": + case "CPH1609": + case "CPY83_I00": + case "cv1": + case "cv3": + case "deb": + case "E5643": + case "ELUGA_A3_Pro": + case "ELUGA_Note": + case "ELUGA_Prim": + case "ELUGA_Ray_X": + case "EverStar_S": + case "F3111": + case "F3113": + case "F3116": + case "F3211": + case "F3213": + case "F3215": + case "F3311": + case "flo": + case "fugu": + case "GiONEE_CBL7513": + case "GiONEE_GBL7319": + case "GIONEE_GBL7360": + case "GIONEE_SWW1609": + case "GIONEE_SWW1627": + case "GIONEE_SWW1631": + case "GIONEE_WBL5708": + case "GIONEE_WBL7365": + case "GIONEE_WBL7519": + case "griffin": + case "htc_e56ml_dtul": + case "hwALE-H": + case "HWBLN-H": + case "HWCAM-H": + case "HWVNS-H": + case "HWWAS-H": + case "i9031": + case "iball8735_9806": + case "Infinix-X572": + case "iris60": + case "itel_S41": + case "j2xlteins": + case "JGZ": + case "K50a40": + case "kate": + case "le_x6": + case "LS-5017": + case "M5c": + case "manning": + case "marino_f": + case "MEIZU_M5": + case "mh": + case "mido": + case "MX6": + case "namath": + case "nicklaus_f": + case "NX541J": + case "NX573J": + case "OnePlus5T": + case "p212": + case "P681": + case "P85": + case "panell_d": + case "panell_dl": + case "panell_ds": + case "panell_dt": + case "PB2-670M": + case "PGN528": + case "PGN610": + case "PGN611": + case "Phantom6": + case "Pixi4-7_3G": + case "Pixi5-10_4G": + case "PLE": + case "PRO7S": + case "Q350": + case "Q4260": + case "Q427": + case "Q4310": + case "Q5": + case "QM16XE_U": + case "QX1": + case "santoni": + case "Slate_Pro": + case "SVP-DTV15": + case "s905x018": + case "taido_row": + case "TB3-730F": + case "TB3-730X": + case "TB3-850F": + case "TB3-850M": + case "tcl_eu": + case "V1": + case "V23GB": + case "V5": + case "vernee_M5": + case "watson": + case "whyred": + case "woods_f": + case "woods_fn": + case "X3_HK": + case "XE2X": + case "XT1663": + case "Z12_PRO": + case "Z80": + deviceNeedsSetOutputSurfaceWorkaround = true; + break; + default: + // Do nothing. + break; + } + switch (Util.MODEL) { + case "AFTA": + case "AFTN": + deviceNeedsSetOutputSurfaceWorkaround = true; + break; + default: + // Do nothing. + break; + } } evaluatedDeviceNeedsSetOutputSurfaceWorkaround = true; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java index 7d78ba03c7..f2f451b3d0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -179,6 +179,7 @@ public interface VideoRendererEventListener { /** Invokes {@link VideoRendererEventListener#onVideoDisabled(DecoderCounters)}. */ public void disabled(DecoderCounters counters) { + counters.ensureUpdated(); if (listener != null) { handler.post( () -> { diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump index 96b0cd259c..d4df3ffeba 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.0.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 26125 + duration = 26122 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 1 track 0: diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump index 96b0cd259c..d4df3ffeba 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.1.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 26125 + duration = 26122 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 1 track 0: diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump index 96b0cd259c..d4df3ffeba 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.2.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 26125 + duration = 26122 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 1 track 0: diff --git a/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump b/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump index 96b0cd259c..d4df3ffeba 100644 --- a/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump +++ b/library/core/src/test/assets/mp3/play-trimmed.mp3.3.dump @@ -1,6 +1,6 @@ seekMap: isSeekable = true - duration = 26125 + duration = 26122 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 1 track 0: diff --git a/library/core/src/test/assets/mp4/sample.mp4.0.dump b/library/core/src/test/assets/mp4/sample.mp4.0.dump index efc804d48b..b05d8250ab 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.0.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -352,6 +352,6 @@ track 1: data = length 229, hash FFF98DF0 sample 44: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.1.dump b/library/core/src/test/assets/mp4/sample.mp4.1.dump index 10104b5e81..84d86f8ccf 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.1.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -304,6 +304,6 @@ track 1: data = length 229, hash FFF98DF0 sample 32: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.2.dump b/library/core/src/test/assets/mp4/sample.mp4.2.dump index 8af96be673..9bbe8caa01 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.2.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -244,6 +244,6 @@ track 1: data = length 229, hash FFF98DF0 sample 17: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.3.dump b/library/core/src/test/assets/mp4/sample.mp4.3.dump index f1259661ed..f210f277b3 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.3.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -184,6 +184,6 @@ track 1: data = length 229, hash FFF98DF0 sample 2: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/ttml/bitmap_percentage_region.xml b/library/core/src/test/assets/ttml/bitmap_percentage_region.xml new file mode 100644 index 0000000000..9631650178 --- /dev/null +++ b/library/core/src/test/assets/ttml/bitmap_percentage_region.xml @@ -0,0 +1,26 @@ + + + + + iVBORw0KGgoAAAANSUhEUgAAAXAAAABICAYAAADvR65LAAAACXBIWXMAAAsTAAALEwEAmpwYAAANIUlEQVR4nO2d/5GjSA+GNVVfALMh4EyucAIXxDiGCWFiwKngukwghA1h74/vxMia/t3qNtjvU7W1u8Ygtbp53agFvL2/v/8hAAAAh+N/j3YAAABAGRBwAAA4KBBwAAA4KC8j4MMwEBHRuq4P9qQ/vdv+yrFOBTECFhxawFNPgnEc6Xw+ExHRPM90u92a+7YXerZ9HEc6nU50Op2IiGhZFprnGSKleOXxCGwxE/BxHLd/uwYkb1+WpeqE1iLBLMuy/XEdX54wRyEW01RCbbeyMQwDnc/nzRYRBfvjWUmN517Ho9V4eDTP0o4YJgLOJ+/pdHLOKMZxpMvlQkRE0zQVn9B8HC3eknmeq2zsBSmI8zw3EUJLG1K85Y/psiyWLu+aHn3WkqP7zzxLO1IwEXB92ezbzsEsQYu3Fge2wX+etcNKaC2iwzDc9cs0TU896wFgL5gKuEug9cldIqxyhk/0c5bNNng7xOMbGYuWcZF9jPgD0IdqAY8JdGx2nkJsYWxdV1rXFcLhoXVcQiktAEA7igScqz+I6MeCotwmt7N4l5ZP+VIntZT4k7NP7Luu7fqKQsc4N3Ytbej+1p9pm67PYrYs4l3SDzlYx7PVeAwdI8f/Hn1Zsm9tP9SOtdz21WYosgVclkAR3QdIpjnkdv77crls4ltaPlU72/N1bKzkTadxUvaRsXItrLrKyfgzPQhLY9fShus4cmzIdms/2Cbvp+NTG2+XDT4Gp3lcNlLbHotDajx7jkcr/3v0Zcm+pf1gNdb02A+NI+mrhO2mjr9sAT+dTj8cldtCAqtn4yVwh5T+ALDvLj9Pp5NTaIdhoMvl4my3r/KGbZ3PZ1qWxbuw6ion89kpzTO3suEbC7IaRbbb98Ovv1cab2lDnsCaZVl+nOjaBlFe6qk0nj3Ho6X/PfqyZN/cdliNtZxxFKqmy52NZws4/0IwoXpWKdhStHMFiG2yLT75SsqE2B9XG/h4evYgO1jvJ39FLXLNXMWhxVHarU0hWdmQcdQlhPrfEletuEyxWcQ71M/yhJPb+XP+k9qfNfFsMR7ZXuo5UeN/bV/6fC3ZN7cd1mONjy3HEJdP8/5sU44/uV8u2QJ+u902Zz4+PojIPe2XjnJgS3N067rSPM/O3JYMXsol2TzPd6I/DAMty7IFWp+4crDI6he5Hw8YCwFf15Wu1+uWS+OT2LK23coGjwV5c1VKX8v+4v/LWbpFvGP9zPblmJEzo9PplJTTJaqLp+V4lNuXZaHr9Rr1vdb/0r6M+Vqyb247rMeaFmk50eRtevLgSjdxW1KoqkKJXR5KR2vFh4/vynHJf8cuH7Wv67pug1BfCukFBtkO/lGR/ozjiEoYig8+LZyMZbxj/ewSDbm9FzXjUZ78epLjmr23ILUvc3zt0c7WY0366JsMuK48mi9iMjIAOemTGm632zawXTnMlEueHF907kzvyyebLwcG3Pgu7y3jbVmp1JKa8ejL70vhaC3gqX2Z42uPdrYea/pHWPooNYy/V9pPxQIuBVrDq7rsrCWy5lteusv8plU6g48nbWtk+3LypsAN4h2G48OTFR0PGb9Hx6fG1x7tbDnWfIKshf3r62ubAJfc9p8s4PohUvJvmUti5Hadd7SaFXAOVua82GavdIbuZNAWxPubI1351fj6qHa2GGvrutI0TbQsy12OnOg7Z9+kjNAl0nKbD520b4Er5wTAM5OSmtxLGqnG1yO1MxVebNV5dpkaJkqraksWcLnSHMofhbbV5HpS/Ou9AEV0/8t8tIF0RBDv/1Nb2dWTGl8f2c6asea6Q1nDQk70fWOPq3IlRLKAy/IcLq/hMhgJp0x4u5x1t+4Ea/HW+Sq9kiwXcvn7oBzEO8yjJikl1Pjao509xlpokVQjq+ykDzHNzFrElHWY7Jg2oJ22EG25KOrLoctLD6vKF70SfT6f70rPdHrIZ9M1EGWbYvSoKOhVtRDCKt57oEU8ZXxC5XMWz0ap9b/GV8t2+tphOdZ4kVXa0Hokt43jGNTOHIpupWeHXY3i7ZYnmMwNuWzLhQAim7pzeSxd6cK29fPJXXWejBbr0JoC0f2g1O2z+mHsYSOXmng/mh7xlPGRN8oxfJ7M85x8I08r/2t8rdk3tR1WY01mHLRNmXom+r5ZjO3IK4GSeBcLuEugLZ79HUM3kn1ipmkyXSzlSxvuJA5+ik3dOVz3mfpL63p8AH+ee3I+0kYONfHeA63j6YsP0f154EoL9Pa/xtfadqa0w3Ks+c5v12STv+9Dp55DFAl4LD1ilcJg5G2orudZsL1QmWLIH+mv63syP6UvjXx3ovF+8upB2tPPEPH5patrSuIaa3utjVj8UvyQlMY7xb6ln759U+LZajzGzoMe/lv5WrNvajtqxpq0JdMxof154it1TB8np+/e3t/f/yR98z94lu0TcIv8W4p9TWzGzy85DT35LNQul+3UqwzffvLzmF+S3Pr21LbX2EiJX8yPmF8p8a7t55R25Prt8qfFeCSyufK18D/lmKXnT+q+OeM6d6yN40hfX19E9D1Lzz2HdMaCKF83swUcAABeHSngn5+fD7vj1eSdmAAAAPoDAQcAgIMCAQcAgIMCAQcAgIMCAQcAgAL2cCcwBBwAADKRVSePfOY6yggBAOCgvP39B/od4p9fvxAgAB7EX79/vz3ahz2DFAoAABwUCDgAABwUCDgAABwUCPhOaP0QsBb08Hnvcdm7f6141XbvDQh4Q1IHOb8Pj4iy3kj9SHr4vPe47N2/Vrxqu/cIBNyYcRzvngvMyGcY+14JR0S7fVGBi5DP/LhRoro62UfFJdX/I/abBa/a7r0BATeEX5cUeuMOvwj6mS89+X2f/D7DPb7+LMTR/QevAwTcCC3erlcpyT+h92cehR4+HzEurwD6ZR9AwA3gGZt8756cZfObN3xv39nLbbk59PD5iHF5BdAv+wECboDrXXhyhr2uK63rGhzsRzwRevh8xLi8AuiXfQABN8KXOkklVLHi25ZbyuX6fk05mO948gdNL+jm2ukRF72vhf8lPliW5vn6JnRs3ifFh979AtxAwI0JLWD6CJVl6W1sw/WW+9DLhF37SH9zy8FcPvNnWgAvl8tmL8dO67j47JX47xP8mA86/Vbit68d7K/2Sy+iy+9LH5Zlcba1d78APxBwY/iEzxXEUFkWb5Mi4bLrqm5JqYzx2S3xWQsB+yavUPYQl5g9fYyY/9qXFB+GYaDL5eK1WVNjLY+p/ZeL6LLiRsM/WqH29uoX4AYCbgDPKHjg8ozKugztdDptthhpU89q9OxOpndcteq1LMtC0zRtbWekvy2qF3Lj4qPG/5K+keKt9+PPa8eObIe8F0GjhViO4VIfrPoF+IGAG7CuK83z7Myd8iC2uGyc5/nuB2EYBlqWhS6Xy2ZTzpakEPC+t9tty/P6Zl6lrOtK1+t1y3XySdp6ppUblxb+1/YN25C2WTyv12t+UP5Djj3+v15gn6Zp+zcR3flQ8yNv1S/ADwTcCB6Irhyq/HfNZbG+fF/XdTtB9YyaRZr3k3a5KsZ6Bv4ocuKyBx9038gfCD0ZqJ2psoiG9tfb2Hei7/FbYn8P/fLsQMANud1u2+DUQk50P6MpEfGc9INv0bL0eHtmD+0o7RseL67jhW78yvErp3ImlLcusQ3aAgE3RtZ8y+oPubBzPp+7XDpKkUCucV9w3/CPuuuuXfn/VuNFVyhZCjhoDwS8Ibfbbcs5E92vzo/jiPwfIKI2C8opyAol1wInRHz/QMA7oPOaODEAk3LjV4tUhBbvaZrurtQ+Pj62xUawXyDgnZCLNwAwehGzF/rGHn01iPz1MYCAd6S3eMsfjNht1KAfe/gxl+sj4LhAwA3gG2aIyFuy5buhphVSJHw3kvQQkNoqikfTwn8up4uVCbZ8doguE9QzcFwpHgMIuAG6bNC1GKTv7GstaLKWl4ju8p1E9TdpxGwzuu1HqIjp4b9cE9F9Q/TdP/M8V93I40Pbkp/pNoP9AgE3Rp/sRPezmWmaur2GikWCxYAfytRjduV6tAB/3kKQrGntP894WbzlA7N0CWGL9Jd8/EPIPtg3EHAD+GTU9d46ZRK6nT6UUolt4+36e/I2adet/UTuhzelEvNLV96UpI1axCXVbor/NT7Iu3ddKbaaxy/E2sxjY1mWH1eP8imCJcdv2S/gnre///x5tA+75p9fv7IC5Mstxy69+SW6vsd3+rZJmyEb8iW97M/5fN5KxT4/P7Pr0lP9kljasIhLiBT/LXxw2alN1cT8cn1X2ib6FvDc2Fv2y1+/f79F3H9pIOARcgX8SIzjSF9fX0RUJuAAtAYCHgYplBcGuU4Ajg0E/ImRl8b6clU/EQ8AcDwg4E+MrECRz2Ym+vnSAIg4AMcDAv7E6OdK88KRrpDBm1EAOCYQ8CeGH6JF9PNFE0Q/X/QAADgWqEKJ8AxVKJzv1nXgR7grErw2qEIJAwGP8AwCDsBRgYCH+RdHEwkWLXE/8gAAAABJRU5ErkJggg== + + + iVBORw0KGgoAAAANSUhEUgAAAaAAAAAkCAIAAABAAnl0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAIBklEQVR4nO1d65GrPAz1nfkKSAu4FWglqeXWQFohrZhW7o8zaBS/kB+QhE/nx86uA5YsnT1+CHb/3G43o1AoFFfEf592QKFQKI6CCpxCobgsVOAUCsVloQL3SxiGwRizruuht5TiBBM/hJ+Lxs85XAQVuJ/BOI7TNBljlmV5vV4H3XKCVxfGz0Xj5xwuRaXAjeOIb7ygoN05J58QqCuOoh7+PyAu8sZULjK3lOIEE5fBB6ORT1MK105fjcANwzBNk7XWU/1xHB+PhzFmnmehPOEWa63X7pxblsU5d8lZpSOQi2maEK4jZoUTTCjaoWmKokbgrLWQJOdc2I74VnRLd6Gfx+OBWUU1jlAXWMWZ0Bx9FeoFzhOyYRhI9Spmj2VZaJGM/jEdoU/VOMOCoNH4WmiOvg3FApcSstSyTg5ODpwmQOCmaVK6ABqH74fm6KsgFTjUkg0TMt5I7VC39sIzWGI3jOMY5Q05kLIVeiL0TXJZx4c2hO3hj5QOnpeObh9qopon8rTmTVTctbtT2R1U46hTDlekqdHDipHWpanuSkAkcFRLNix8tH+kdnx9PB6QucbC8+v1ggkgLNeSS8YY51xYkeAlcPOeeBQxUkShnqPdUo0l31WIYRhQhHHOPZ/PqJ/c1v1+hxUUbbzL8COndSbyIfUlbh9kojqAYbhCi8iXZyLqMPXj1cRS6aBoUAs8D6+JmpNQa3fI0XuL0pRC9/T1SlOR0RAigeOJ4Y3cM6+9y1Hrsiywa60dhoGvXHBCxy+GRS86dDt95X56zIYAhd1aa0mPotfYoKCcAepcfDh8LNQJL1XzKw2r6Idu0OIiE4dMBKLe9jXRHkCTTatzjv+2cxPmnR4IO/LrBSF8ciJa7o8u5aJPXUiolYKE7fI0pXBE+rqkqdSoB5HAQS+5017+SNRI17o84YEOU0rKfaDTuujGAb55Q4DQcNGkINJAaP0IPeLX8N7orrxY0KfoEz9623wSPs7RVHDmeeZzD7kU3iKJwAkmGgMosUhx82pWQB0n4TZ1S9wouj1Prd1OMmwvSlMKjemLClOmZ3maGjkjErjX6wWT9/vdBCtzSj8C3fcBHAicMYZPs+u6zvNsNsmARnhrXQ6UaBGLYRicc9gq8lDyiNPo6MAFhiisy7JgpGQabNudjbEm5Vnk8s2Fj9QtxdF1XZ/PJzlALlVH4AQT7QGUWAQ/6TeTFl9yNfHAucEjIDwPklArg122F6Upher04UqbLgY2pqmRM2VVVJtY91L+Tnu8kBvCJGbeBYKDO4yLvYUh7Qc97V7X1TvRN9u6Bu3rui7Lgq52F0TkTChq9Cn4RLZMp+eqdiNwgoleAdy16JGe2ruM0SPe7i0Sau2iiO11KEofpQnpozk7WgxsSVM7ZwoEjozt7k9PA7mUp++uY5TIzCj4NZ45osIu4XgWMU3xkIIomKDIVhcGn5CaoiBXB7DIYkfAQywZipIioZYQQrbXofp3BJqVmSxbRt3OmQKBIyHjjZigzGFsiy5kxnGkkfdKOfWQCZZl5WPyx1uO5U95vOnXMCHzevPar4H2AH4EfDnz9+9fzEbyN6Ik1MrgCLbXITUQnsru6WvkzI7ARYPr1XSonW+tu6w7UupGR358Mjkz65n5aheOHVHTnIFGbyb8yKL4HLQE8HzgFMxthT9+AN/4LNQuvoHtuzjHqzrO7AhcWJ82QU3aM790/bME3qTB8w3Oof1+v0NeT0BGdyR6RMsBCqPX4eUFrjGAHwEO8vl5EzJo09XDdnwD278E1ZzZETi31Xf5ZjhcKPLGXsdGtPk1QS3ZGDPPM2fVyccxjcXicKWG3kLhO61ocybaA/gpQObM9hTrdPCrhN/AdgmiO62+qObMjsBR+RkzCSq19Cm2pWgnDepFXL4k9NbA3ePID1lSTxLw+kAL+DEc9ex9E/3x19ErgN8AyA1NRZmnTyTUSuGrzmFTA+le8Y8are5hv8hAD56YYE3Bl299J2T+4Dg/0eMn9HxOa/y14ZWgaZqiT9bQNdHKdNErcjxt3uKXtxcNAc4fuixqNNExgB3hMYfvGzjGcYySfxcSaqVQx/aDmJAaSHQh0t1oNWekr2rZ2IMgaO8yMGvt/X53rPRLi3PeOX3PS7c29iZZKfiTNaEhLI/pGjoZoWsQByF9vRFx+Y4KXwb87YiD9rYdTfQKYBd4KabvQy5hunVbIaj0JEFCLYmHebafwIToQGghctCJSiNnCgRu6foH4EIT3rmpe3/QmTdibMS5Lrue1+tlt2Njr2fafWMWRaBD6/I9CM1L5l3saPdqSqK6bG/s0pl3d6XoZaJXALuAS9W0vZQavdJuTyqEH/HDmRQk1Ep5WMT2o5kQpo+cmee5b3UxY9SUcEYqcKl9qHChkULqdrf9yXLPolewt+w1t2jiU53TbMzbn8+ne38HGFdykaXDF+KQ21D0cAzmpdCHlG/54dAsZ4MHFYsikEJHE10CWDGosJHrDh+mCbQMVJzeX0dP+Ry1LqFWiAq2Z9KUQnv6woVIRc+ZW1o48+d2u2U+BrBYC+Wmy7kJP6QEJIsX/q9quKhH/wlOWORKjSj0J/XHW/g18tWW51vKAZOIan44UZ8rIhBFXxONASy1KPTEbe9LRrlk3nctpjBHRkatKIRsrzPRmL5M7jqmKRyakDMigVMoFIpfhP5fVIVCcVmowCkUistCBU6hUFwWKnAKheKyUIFTKBSXhQqcQqG4LFTgFArFZfEPuuTdBr3uWzgAAAAASUVORK5CYII= + + + +