diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 80d9badfb9..55ea4c3235 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,15 +5,37 @@ * 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. +* Captions: + * Support PNG subtitles in SMPTE-TT + ([#1583](https://github.com/google/ExoPlayer/issues/1583)). * 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`). +* Prevent Cea608Decoder from generating Subtitles with null Cues list. +* 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`. + * 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`). +* Disable post processing on Nvidia devices, as it breaks decode-only frame + skippping. +* Workaround for MiTV (dangal) issue when swapping output surface + ([#5169](https://github.com/google/ExoPlayer/issues/5169)). +* DownloadManager: + * Create only one task for all DownloadActions for the same content. + * Rename TaskState to DownloadState. * MP3: Fix issue where streams would play twice on Samsung devices ([#4519](https://github.com/google/ExoPlayer/issues/4519)). + ### 2.9.2 ### * HLS: @@ -61,10 +83,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 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", + "DASH (clear,MP4,H264)", + 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", + "Tears of Steel (HLS)", + 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", + "HLS Basic (TS)", + 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", "Dizzy (MP4)", 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..5278776070 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; @@ -50,6 +49,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,6 +58,10 @@ public class MainActivity extends AppCompatActivity private MediaQueueListAdapter mediaQueueListAdapter; private CastContext castContext; + public MainActivity() { + mediaItemBuilder = new MediaItem.Builder(); + } + // Activity lifecycle methods. @Override @@ -154,7 +159,14 @@ 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); + playerManager.addItem( + mediaItemBuilder + .clear() + .setMedia(sample.uri) + .setTitle(sample.name) + .setMimeType(sample.mimeType) + .build()); mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1); }); return dialogList; @@ -254,19 +266,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/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..9b72df8d98 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; 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..adb0ae95ca 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,7 +17,7 @@ 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.DownloadManager.DownloadState; import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.scheduler.PlatformScheduler; import com.google.android.exoplayer2.ui.DownloadNotificationUtil; @@ -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,41 @@ 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) { + protected void onDownloadStateChanged(DownloadState downloadState) { + if (downloadState.action.isRemoveAction) { return; } 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.action.data)); + } 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.action.data)); + } 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..6afacca064 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,43 @@ 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.DownloadManager.DownloadState; import com.google.android.exoplayer2.offline.DownloadService; 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.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 +120,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,11 +144,11 @@ public class DownloadTracker implements DownloadManager.Listener { } @Override - public void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState) { - DownloadAction action = taskState.action; + public void onDownloadStateChanged(DownloadManager downloadManager, DownloadState downloadState) { + DownloadAction action = downloadState.action; Uri uri = action.uri; - if ((action.isRemoveAction && taskState.state == TaskState.STATE_COMPLETED) - || (!action.isRemoveAction && taskState.state == TaskState.STATE_FAILED)) { + if ((action.isRemoveAction && downloadState.state == DownloadState.STATE_COMPLETED) + || (!action.isRemoveAction && downloadState.state == DownloadState.STATE_FAILED)) { // A download has been removed, or has failed. Stop tracking it. if (trackedDownloadStates.remove(uri) != null) { handleTrackedDownloadStatesChanged(); @@ -192,15 +203,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 +220,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..75a0905733 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); 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/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/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..b578467933 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 @@ -1693,7 +1693,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/offline/DownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java index 2c7b5069b9..d809ab4754 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; @@ -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/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 4a76c80d64..997f4e09a2 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,11 +15,12 @@ */ 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.DownloadManager.DownloadState.FAILURE_REASON_NONE; +import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.FAILURE_REASON_UNKNOWN; +import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_COMPLETED; +import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_FAILED; +import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_QUEUED; +import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_STARTED; import android.os.ConditionVariable; import android.os.Handler; @@ -35,6 +36,7 @@ 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; @@ -58,41 +60,40 @@ 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); } - /** 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; 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 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 int nextTaskId; private boolean initialized; private boolean released; private boolean downloadsStopped; @@ -113,8 +114,8 @@ public final class DownloadManager { * * @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. */ public DownloadManager( File actionFile, @@ -123,12 +124,12 @@ public final class DownloadManager { int minRetryCount) { this.actionFile = new ActionFile(actionFile); this.downloaderFactory = downloaderFactory; - this.maxActiveDownloadTasks = maxSimultaneousDownloads; + this.maxActiveDownloads = maxSimultaneousDownloads; this.minRetryCount = minRetryCount; this.downloadsStopped = true; - tasks = new ArrayList<>(); - activeDownloadTasks = new ArrayList<>(); + downloads = new ArrayList<>(); + activeDownloads = new ArrayList<>(); Looper looper = Looper.myLooper(); if (looper == null) { @@ -164,85 +165,78 @@ 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(); + maybeStartDownloads(); logd("Downloads are started"); } } - /** 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() { Assertions.checkState(!released); if (!downloadsStopped) { downloadsStopped = true; - for (int i = 0; i < activeDownloadTasks.size(); i++) { - activeDownloadTasks.get(i).stop(); + for (int i = 0; i < activeDownloads.size(); i++) { + activeDownloads.get(i).stop(); } logd("Downloads are stopping"); } } /** - * 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); + Download download = getDownloadForAction(action); if (initialized) { 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 + maybeStartDownloads(); + if (download.state == STATE_QUEUED) { + // Download 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); + notifyListenersDownloadStateChange(download); } } - 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 +247,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).isStarted()) { return false; } } @@ -268,16 +262,17 @@ 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; } released = true; - for (int i = 0; i < tasks.size(); i++) { - tasks.get(i).stop(); + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).stop(); } final ConditionVariable fileIOFinishedCondition = new ConditionVariable(); fileIOHandler.post(fileIOFinishedCondition::open); @@ -286,66 +281,46 @@ 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 Download getDownloadForAction(DownloadAction action) { + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.action.isSameMedia(action)) { + download.addAction(action); + logd("Action is added to existing download", download); + return download; + } + } + Download download = new Download(this, downloaderFactory, action, minRetryCount); + downloads.add(download); + logd("Download is added", download); + return download; } /** - * Iterates through the task queue and starts any task if all of the following are true: + * Iterates through the download queue and starts any download 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. + *
  • 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() { + private void maybeStartDownloads() { 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()) { + boolean skipDownloads = downloadsStopped || activeDownloads.size() == maxActiveDownloads; + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (!download.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(); + boolean isRemoveAction = download.action.isRemoveAction; + if (isRemoveAction || !skipDownloads) { + download.start(); if (!isRemoveAction) { - activeDownloadTasks.add(task); - skipDownloadActions = activeDownloadTasks.size() == maxActiveDownloadTasks; + activeDownloads.add(download); + skipDownloads = activeDownloads.size() == maxActiveDownloads; } } } @@ -361,30 +336,30 @@ public final class DownloadManager { } } - private void onTaskStateChange(Task task) { + private void onDownloadStateChange(Download download) { if (released) { return; } - boolean stopped = !task.isStarted(); + boolean stopped = !download.isStarted(); if (stopped) { - activeDownloadTasks.remove(task); + activeDownloads.remove(download); } - notifyListenersTaskStateChange(task); - if (task.isFinished()) { - tasks.remove(task); + notifyListenersDownloadStateChange(download); + if (download.isFinished()) { + downloads.remove(download); saveActions(); } if (stopped) { - maybeStartTasks(); + maybeStartDownloads(); 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); } } @@ -405,27 +380,27 @@ public final class DownloadManager { if (released) { return; } - List pendingTasks = new ArrayList<>(tasks); - tasks.clear(); + List pendingDownloads = new ArrayList<>(downloads); + downloads.clear(); for (DownloadAction action : actions) { - addTaskForAction(action); + getDownloadForAction(action); } - logd("Tasks are created."); + logd("Downloads are created."); initialized = true; for (Listener listener : listeners) { listener.onInitialized(DownloadManager.this); } - if (!pendingTasks.isEmpty()) { - tasks.addAll(pendingTasks); + if (!pendingDownloads.isEmpty()) { + downloads.addAll(pendingDownloads); 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 + maybeStartDownloads(); + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.state == STATE_QUEUED) { + // Download 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); + notifyListenersDownloadStateChange(download); } } }); @@ -436,14 +411,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,39 +433,46 @@ public final class DownloadManager { } } - private static void logd(String message, Task task) { - logd(message + ": " + task); + private static void logd(String message, Download download) { + logd(message + ": " + download); } - /** Represents state of a task. */ - public static final class TaskState { + /** Represents state of a download. */ + public static final class DownloadState { /** - * Task states. One of {@link #STATE_QUEUED}, {@link #STATE_STARTED}, {@link #STATE_COMPLETED}, - * {@link #STATE_CANCELED} or {@link #STATE_FAILED}. + * Download states. One of {@link #STATE_QUEUED}, {@link #STATE_STARTED}, {@link + * #STATE_COMPLETED} 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}) + @IntDef({STATE_QUEUED, STATE_STARTED, STATE_COMPLETED, STATE_FAILED}) public @interface State {} - /** The task is waiting to be started. */ + /** The download is waiting to be started. */ public static final int STATE_QUEUED = 0; - /** The task is currently started. */ + /** The download is currently started. */ public static final int STATE_STARTED = 1; - /** The task completed. */ + /** The download 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; + /** The download failed. */ + public static final int STATE_FAILED = 3; + + /** 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; /** Returns the state string for the given state value. */ public static String getStateString(@State int state) { @@ -500,8 +483,6 @@ public final class DownloadManager { return "STARTED"; case STATE_COMPLETED: return "COMPLETED"; - case STATE_CANCELED: - return "CANCELED"; case STATE_FAILED: return "FAILED"; default: @@ -509,97 +490,151 @@ public final class DownloadManager { } } - /** The unique task id. */ - public final int taskId; + /** 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 action being executed. */ public final DownloadAction action; - /** The state of the task. */ + /** The state of the download. */ 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. - */ + /** 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; - /** If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise null. */ - @Nullable public final Throwable error; + /** + * If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise {@link + * #FAILURE_REASON_NONE}. + */ + @FailureReason public final int failureReason; - private TaskState( - int taskId, + private DownloadState( DownloadAction action, @State int state, float downloadPercentage, long downloadedBytes, - @Nullable Throwable error) { - this.taskId = taskId; + long totalBytes, + @FailureReason int failureReason, + long startTimeMs) { + Assertions.checkState( + failureReason == FAILURE_REASON_NONE ? state != STATE_FAILED : state == STATE_FAILED); + this.id = action.id; this.action = action; this.state = state; this.downloadPercentage = downloadPercentage; this.downloadedBytes = downloadedBytes; - this.error = error; + this.totalBytes = totalBytes; + this.failureReason = failureReason; + this.startTimeMs = startTimeMs; + updateTimeMs = System.currentTimeMillis(); } } - private static final class Task implements Runnable { + private static final class Download { /** Target states for the download thread. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_COMPLETED, STATE_QUEUED, STATE_CANCELED}) + @IntDef({STATE_QUEUED, STATE_COMPLETED}) public @interface TargetState {} - 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; + private final long startTimeMs; + private final ArrayDeque actionQueue; + private DownloadAction action; + /** The current state of the download. */ + @DownloadState.State private int state; /** - * When started, this is the target state that the task will transition to when the download + * When started, this is the target state that the download will transition to when the download * thread stops. */ @TargetState private volatile int targetState; @MonotonicNonNull private Downloader downloader; - @MonotonicNonNull private Thread thread; - @MonotonicNonNull private Throwable error; + @MonotonicNonNull private DownloadThread downloadThread; + @MonotonicNonNull @DownloadState.FailureReason private int failureReason; - private Task( - int id, + private Download( DownloadManager downloadManager, DownloaderFactory downloaderFactory, DownloadAction action, int minRetryCount) { - this.id = id; + this.id = action.id; this.downloadManager = downloadManager; this.downloaderFactory = downloaderFactory; this.action = action; this.minRetryCount = minRetryCount; + this.startTimeMs = System.currentTimeMillis(); state = STATE_QUEUED; targetState = STATE_COMPLETED; + actionQueue = new ArrayDeque<>(); + actionQueue.add(action); } - public TaskState getTaskState() { + public void addAction(DownloadAction newAction) { + Assertions.checkState(action.type.equals(newAction.type)); + actionQueue.add(newAction); + DownloadAction updatedAction = DownloadActionUtil.mergeActions(actionQueue); + if (action.equals(updatedAction)) { + return; + } + if (state == STATE_STARTED) { + if (targetState == STATE_COMPLETED) { + stopDownloadThread(); + } + } else { + Assertions.checkState(state == STATE_QUEUED); + action = updatedAction; + downloadManager.onDownloadStateChange(this); + } + } + + 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); + return new DownloadState( + action, + state, + downloadPercentage, + downloadedBytes, + totalBytes, + failureReason, + startTimeMs); } - /** Returns whether the task is finished. */ + /** Returns whether the download is finished. */ public boolean isFinished() { - return state == STATE_FAILED || state == STATE_COMPLETED || state == STATE_CANCELED; + return state == STATE_FAILED || state == STATE_COMPLETED; } - /** Returns whether the task is started. */ + /** Returns whether the download is started. */ public boolean isStarted() { return state == STATE_STARTED; } @@ -610,9 +645,9 @@ public final class DownloadManager { + ' ' + (action.isRemoveAction ? "remove" : "download") + ' ' - + TaskState.getStateString(state) + + DownloadState.getStateString(state) + ' ' - + TaskState.getStateString(targetState); + + DownloadState.getStateString(targetState); } public boolean canStart() { @@ -622,77 +657,108 @@ public final class DownloadManager { public void start() { if (state == STATE_QUEUED) { state = STATE_STARTED; + action = actionQueue.peek(); targetState = STATE_COMPLETED; - downloadManager.onTaskStateChange(this); downloader = downloaderFactory.createDownloader(action); - thread = new Thread(this); - thread.start(); - } - } - - public void cancel() { - if (state == STATE_STARTED) { - stopDownloadThread(STATE_CANCELED); - } else if (state == STATE_QUEUED) { - state = STATE_CANCELED; - downloadManager.handler.post(() -> downloadManager.onTaskStateChange(this)); + downloadThread = + new DownloadThread( + this, downloader, action.isRemoveAction, minRetryCount, downloadManager.handler); + downloadManager.onDownloadStateChange(this); } } public void stop() { - if (state == STATE_STARTED && targetState == STATE_COMPLETED) { - stopDownloadThread(STATE_QUEUED); + if (state == STATE_STARTED) { + stopDownloadThread(); } } // 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() { + this.targetState = DownloadState.STATE_QUEUED; + 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; + state = targetState; + failureReason = FAILURE_REASON_NONE; + if (targetState == STATE_COMPLETED) { + if (finalError != null) { + state = STATE_FAILED; + failureReason = FAILURE_REASON_UNKNOWN; + } else { + actionQueue.remove(); + if (!actionQueue.isEmpty()) { + // Don't continue running. Wait to be restarted by maybeStartDownloads(). + state = STATE_QUEUED; + action = actionQueue.peek(); + } + } } - state = finalState; - error = finalError; - downloadManager.onTaskStateChange(this); + downloadManager.onDownloadStateChange(this); + } + } + + 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 +768,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) { 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..d49b33d2ae 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,7 +24,7 @@ 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.offline.DownloadManager.DownloadState; import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.scheduler.RequirementsWatcher; import com.google.android.exoplayer2.scheduler.Scheduler; @@ -71,9 +71,9 @@ 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. + // Keep the requirements helper for each DownloadService as long as there are downloads (and the + // process is running). This allows downloads to resume when there's no scheduler. It may also + // allow downloads 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 = @@ -99,7 +99,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 +110,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 +128,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. @@ -338,29 +338,29 @@ public abstract class DownloadService extends Service { * *

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. } @@ -428,10 +428,11 @@ public abstract class DownloadService extends Service { } @Override - public void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState) { - DownloadService.this.onTaskStateChanged(taskState); + public void onDownloadStateChanged( + DownloadManager downloadManager, DownloadState downloadState) { + DownloadService.this.onDownloadStateChanged(downloadState); if (foregroundNotificationUpdater != null) { - if (taskState.state == TaskState.STATE_STARTED) { + if (downloadState.state == DownloadState.STATE_STARTED) { foregroundNotificationUpdater.startPeriodicUpdates(); } else { foregroundNotificationUpdater.update(); @@ -471,8 +472,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); 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/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/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 26667e641f..e3114298f3 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 @@ -826,7 +826,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(); this.uid = new Object(); } @@ -951,10 +951,18 @@ public class ConcatenatingMediaSource extends CompositeMediaSourceThis 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/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..b39a5d19f0 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; 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..5c8350cb1d 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 @@ -273,19 +275,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)); } }; 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/DataSpec.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index c33c7c823f..ef0efe6140 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 @@ -108,10 +108,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/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index a769e9acac..8641746c74 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 @@ -62,7 +62,7 @@ public interface Cache { void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan); } - + /** * Thrown when an error is encountered when writing data. */ @@ -82,7 +82,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. @@ -223,25 +223,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/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index eaf72cf7fb..1b4b28d67e 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,10 +42,6 @@ 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 { @@ -303,7 +299,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 +484,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 +499,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/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/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 1fd6dc63bc..8bcf1758fa 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; @@ -265,7 +268,7 @@ 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); return SimpleCacheSpan.getCacheFile( @@ -290,7 +293,7 @@ public final class SimpleCache implements Cache { return; } // Check if the span conflicts with the set content length - long length = ContentMetadataInternal.getContentLength(cachedContent.getMetadata()); + long length = ContentMetadata.getContentLength(cachedContent.getMetadata()); if (length != C.LENGTH_UNSET) { Assertions.checkState((span.position + span.length) <= length); } @@ -311,9 +314,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 +333,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 +370,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; @@ -431,27 +422,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 +446,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/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 67586fe672..49f6be9763 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; @@ -484,7 +484,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { format, codecMaxValues, codecOperatingRate, - deviceNeedsAutoFrcWorkaround, + deviceNeedsNoPostProcessWorkaround, tunnelingAudioSessionId); if (surface == null) { Assertions.checkState(shouldUseDummySurface(codecInfo)); @@ -1036,8 +1036,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 +1047,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 +1071,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) { @@ -1265,21 +1266,21 @@ 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); } /* @@ -1305,163 +1306,171 @@ 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. + 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 "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/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= + + + +