From de3e9abaa241dc2aa66e5d02ba8b7bd35e0d8f00 Mon Sep 17 00:00:00 2001 From: Sam Judd Date: Mon, 17 Mar 2014 13:07:22 -0700 Subject: [PATCH] Best effort fix to prevent stretched videos. To avoid increasing load times, we now only parse video headers asynchronously while the user scrolls. We make a best effort attempt to prefetch video headers while we're scrolling to avoid the layout jumping around when we update dimensions for an item that's visible to the user. Bug: 13505062 Change-Id: Ib7d7835c39d50f22f45db5673ec4c49d84b81124 --- src/com/android/camera/CameraActivity.java | 14 ++ src/com/android/camera/VideoModule.java | 2 + .../data/AbstractLocalDataAdapterWrapper.java | 27 +++- src/com/android/camera/data/CameraDataAdapter.java | 66 ++++++-- .../android/camera/data/FixedFirstDataAdapter.java | 8 +- .../android/camera/data/FixedLastDataAdapter.java | 8 +- src/com/android/camera/data/LocalDataAdapter.java | 9 +- src/com/android/camera/data/LocalDataUtil.java | 1 + src/com/android/camera/data/LocalMediaData.java | 87 ++++++----- src/com/android/camera/data/MetadataLoader.java | 34 +++- .../camera/data/VideoRotationMetadataLoader.java | 55 +++++++ .../camera/filmstrip/FilmstripController.java | 10 ++ src/com/android/camera/widget/FilmstripView.java | 4 + src/com/android/camera/widget/Preloader.java | 174 +++++++++++++++++++++ 14 files changed, 433 insertions(+), 66 deletions(-) create mode 100644 src/com/android/camera/data/VideoRotationMetadataLoader.java create mode 100644 src/com/android/camera/widget/Preloader.java diff --git a/src/com/android/camera/CameraActivity.java b/src/com/android/camera/CameraActivity.java index da0c559a2..e07750f8a 100644 --- a/src/com/android/camera/CameraActivity.java +++ b/src/com/android/camera/CameraActivity.java @@ -43,6 +43,7 @@ import android.net.Uri; import android.nfc.NfcAdapter; import android.nfc.NfcAdapter.CreateBeamUrisCallback; import android.nfc.NfcEvent; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -122,6 +123,7 @@ import com.android.camera.util.PhotoSphereHelper.PanoramaViewHelper; import com.android.camera.util.ReleaseDialogHelper; import com.android.camera.util.UsageStatistics; import com.android.camera.widget.FilmstripView; +import com.android.camera.widget.Preloader; import com.android.camera2.R; import com.google.common.logging.eventprotos; import com.google.common.logging.eventprotos.CameraEvent.InteractionCause; @@ -163,6 +165,8 @@ public class CameraActivity extends Activity private static final int MSG_CLEAR_SCREEN_ON_FLAG = 2; private static final long SCREEN_DELAY_MS = 2 * 60 * 1000; // 2 mins. private static final int MAX_PEEK_BITMAP_PIXELS = 1600000; // 1.6 * 4 MBs. + /** Load metadata for 10 items ahead of our current. */ + private static final int FILMSTRIP_PRELOAD_AHEAD_ITEMS = 10; /** Should be used wherever a context is needed. */ private Context mAppContext; @@ -228,6 +232,7 @@ public class CameraActivity extends Activity private long mOnCreateTime; private Menu mActionBarMenu; + private Preloader mPreloader; @Override public CameraAppUI getCameraAppUI() { @@ -630,6 +635,11 @@ public class CameraActivity extends Activity } }); } + + @Override + public void onScroll(int firstVisiblePosition, int visibleItemCount, int totalItemCount) { + mPreloader.onScroll(null /*absListView*/, firstVisiblePosition, visibleItemCount, totalItemCount); + } }; private final LocalDataAdapter.LocalDataListener mLocalDataListener = @@ -1268,6 +1278,9 @@ public class CameraActivity extends Activity new ColorDrawable(getResources().getColor(R.color.photo_placeholder))); mDataAdapter.setLocalDataListener(mLocalDataListener); + mPreloader = new Preloader(FILMSTRIP_PRELOAD_AHEAD_ITEMS, mDataAdapter, + mDataAdapter); + mCameraAppUI.getFilmstripContentPanel().setFilmstripListener(mFilmstripListener); mLocationManager = new LocationManager(mAppContext); @@ -1424,6 +1437,7 @@ public class CameraActivity extends Activity mLocalImagesObserver.setForegroundChangeListener(null); mLocalImagesObserver.setActivityPaused(true); mLocalVideosObserver.setActivityPaused(true); + mPreloader.cancelAllLoads(); resetScreenOn(); super.onPause(); } diff --git a/src/com/android/camera/VideoModule.java b/src/com/android/camera/VideoModule.java index 36decc785..92f426c91 100644 --- a/src/com/android/camera/VideoModule.java +++ b/src/com/android/camera/VideoModule.java @@ -1135,6 +1135,8 @@ public class VideoModule extends CameraModule mCurrentVideoValues.put(MediaColumns.DATE_MODIFIED, dateTaken / 1000); mCurrentVideoValues.put(Video.Media.MIME_TYPE, mime); mCurrentVideoValues.put(Video.Media.DATA, path); + mCurrentVideoValues.put(Video.Media.WIDTH, mProfile.videoFrameWidth); + mCurrentVideoValues.put(Video.Media.HEIGHT, mProfile.videoFrameHeight); mCurrentVideoValues.put(Video.Media.RESOLUTION, Integer.toString(mProfile.videoFrameWidth) + "x" + Integer.toString(mProfile.videoFrameHeight)); diff --git a/src/com/android/camera/data/AbstractLocalDataAdapterWrapper.java b/src/com/android/camera/data/AbstractLocalDataAdapterWrapper.java index af69dc99d..9481b5776 100644 --- a/src/com/android/camera/data/AbstractLocalDataAdapterWrapper.java +++ b/src/com/android/camera/data/AbstractLocalDataAdapterWrapper.java @@ -18,6 +18,9 @@ package com.android.camera.data; import android.content.Context; import android.net.Uri; +import android.os.AsyncTask; + +import java.util.List; /** * An abstract {@link LocalDataAdapter} implementation to wrap another @@ -102,12 +105,32 @@ public abstract class AbstractLocalDataAdapterWrapper implements LocalDataAdapte } @Override - public void updateMetadata(int dataId) { - mAdapter.updateMetadata(dataId); + public AsyncTask updateMetadata(int dataId) { + return mAdapter.updateMetadata(dataId); } @Override public boolean isMetadataUpdated(int dataId) { return mAdapter.isMetadataUpdated(dataId); } + + @Override + public List preloadItems(List items) { + return mAdapter.preloadItems(items); + } + + @Override + public void cancelItems(List loadTokens) { + mAdapter.cancelItems(loadTokens); + } + + @Override + public List getItemsInRange(int startPosition, int endPosition) { + return mAdapter.getItemsInRange(startPosition, endPosition); + } + + @Override + public int getCount() { + return mAdapter.getCount(); + } } diff --git a/src/com/android/camera/data/CameraDataAdapter.java b/src/com/android/camera/data/CameraDataAdapter.java index cbb301d8c..5e4c86058 100644 --- a/src/com/android/camera/data/CameraDataAdapter.java +++ b/src/com/android/camera/data/CameraDataAdapter.java @@ -72,12 +72,14 @@ public class CameraDataAdapter implements LocalDataAdapter { @Override public void requestLoad() { QueryTask qtask = new QueryTask(); - qtask.execute(mContext.getContentResolver()); + qtask.execute(mContext); } @Override - public void updateMetadata(int dataId) { - new MetadataUpdateTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, dataId); + public AsyncTask updateMetadata(int dataId) { + MetadataUpdateTask result = new MetadataUpdateTask(); + result.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, dataId); + return result; } @Override @@ -253,6 +255,7 @@ public class CameraDataAdapter implements LocalDataAdapter { for (; pos < mImages.size() && comp.compare(data, mImages.get(pos)) > 0; pos++); mImages.add(pos, data); + updateMetadata(pos); if (mListener != null) { mListener.onDataInserted(pos, data); } @@ -269,6 +272,40 @@ public class CameraDataAdapter implements LocalDataAdapter { } } + @Override + public List preloadItems(List items) { + List result = new ArrayList(); + for (Integer id : items) { + if (!isMetadataUpdated(id)) { + result.add(updateMetadata(id)); + } + } + return result; + } + + @Override + public void cancelItems(List loadTokens) { + for (AsyncTask asyncTask : loadTokens) { + if (asyncTask != null) { + asyncTask.cancel(false); + } + } + } + + @Override + public List getItemsInRange(int startPosition, int endPosition) { + List result = new ArrayList(); + for (int i = Math.max(0, startPosition); i < endPosition; i++) { + result.add(i); + } + return result; + } + + @Override + public int getCount() { + return getTotalNumber(); + } + private class LoadNewPhotosTask extends AsyncTask> { private long mMinPhotoId; @@ -314,20 +351,23 @@ public class CameraDataAdapter implements LocalDataAdapter { } } - private class QueryTask extends AsyncTask { + private class QueryTask extends AsyncTask { + // The maximum number of data to load metadata for in a single task. + private static final int MAX_METADATA = 5; /** * Loads all the photo and video data in the camera folder in background * and combine them into one single list. * - * @param contentResolvers {@link ContentResolver} to load all the data. + * @param contexts {@link Context} to load all the data. * @return An {@link com.android.camera.data.CameraDataAdapter.QueryTaskResult} containing * all loaded data and the highest photo id in the dataset. */ @Override - protected QueryTaskResult doInBackground(ContentResolver... contentResolvers) { + protected QueryTaskResult doInBackground(Context... contexts) { + final Context context = contexts[0]; + final ContentResolver cr = context.getContentResolver(); LocalDataList l = new LocalDataList(); - final ContentResolver cr = contentResolvers[0]; // Photos List photoData = LocalMediaData.PhotoData.query(cr, LocalMediaData.PhotoData.CONTENT_URI, LocalMediaData.QUERY_ALL_MEDIA_ID); @@ -343,6 +383,12 @@ public class CameraDataAdapter implements LocalDataAdapter { l.addAll(videoData); l.sort(new LocalData.NewestFirstComparator()); + // Load enough metadata so it's already loaded when we open the filmstrip. + for (int i = 0; i < MAX_METADATA && i < l.size(); i++) { + LocalData data = l.get(i); + MetadataLoader.loadMetadata(context, data); + } + return new QueryTaskResult(l, lastPhotoId); } @@ -378,11 +424,9 @@ public class CameraDataAdapter implements LocalDataAdapter { continue; } final LocalData data = mImages.get(id); - if (data.getLocalDataType() != LocalData.LOCAL_IMAGE) { - continue; + if (MetadataLoader.loadMetadata(mContext, data)) { + updatedList.add(id); } - MetadataLoader.loadMetadata(mContext, data); - updatedList.add(id); } return updatedList; } diff --git a/src/com/android/camera/data/FixedFirstDataAdapter.java b/src/com/android/camera/data/FixedFirstDataAdapter.java index 12d836ee7..71ee63239 100644 --- a/src/com/android/camera/data/FixedFirstDataAdapter.java +++ b/src/com/android/camera/data/FixedFirstDataAdapter.java @@ -18,6 +18,7 @@ package com.android.camera.data; import android.content.Context; import android.net.Uri; +import android.os.AsyncTask; import android.view.View; import com.android.camera.filmstrip.DataAdapter; @@ -192,18 +193,19 @@ public class FixedFirstDataAdapter extends AbstractLocalDataAdapterWrapper } @Override - public void updateMetadata(int dataId) { + public AsyncTask updateMetadata(int dataId) { if (dataId > 0) { - mAdapter.updateMetadata(dataId); + return mAdapter.updateMetadata(dataId - 1); } else { MetadataLoader.loadMetadata(mContext, mFirstData); } + return null; } @Override public boolean isMetadataUpdated(int dataId) { if (dataId > 0) { - return mAdapter.isMetadataUpdated(dataId); + return mAdapter.isMetadataUpdated(dataId - 1); } else { return mFirstData.isMetadataUpdated(); } diff --git a/src/com/android/camera/data/FixedLastDataAdapter.java b/src/com/android/camera/data/FixedLastDataAdapter.java index f4afbf19e..c94c1b418 100644 --- a/src/com/android/camera/data/FixedLastDataAdapter.java +++ b/src/com/android/camera/data/FixedLastDataAdapter.java @@ -18,6 +18,7 @@ package com.android.camera.data; import android.content.Context; import android.net.Uri; +import android.os.AsyncTask; import android.view.View; import com.android.camera.filmstrip.ImageData; @@ -154,12 +155,13 @@ public class FixedLastDataAdapter extends AbstractLocalDataAdapterWrapper { } @Override - public void updateMetadata(int dataId) { + public AsyncTask updateMetadata(int dataId) { if (dataId < mAdapter.getTotalNumber()) { - mAdapter.updateMetadata(dataId); + return mAdapter.updateMetadata(dataId); } else { MetadataLoader.loadMetadata(mContext, mLastData); } + return null; } @Override @@ -167,7 +169,7 @@ public class FixedLastDataAdapter extends AbstractLocalDataAdapterWrapper { if (dataId < mAdapter.getTotalNumber()) { return mAdapter.isMetadataUpdated(dataId); } else { - return MetadataLoader.isMetadataLoaded(mLastData); + return MetadataLoader.isMetadataCached(mLastData); } } } diff --git a/src/com/android/camera/data/LocalDataAdapter.java b/src/com/android/camera/data/LocalDataAdapter.java index 90b895a7f..e5047eec1 100644 --- a/src/com/android/camera/data/LocalDataAdapter.java +++ b/src/com/android/camera/data/LocalDataAdapter.java @@ -18,7 +18,9 @@ package com.android.camera.data; import android.net.Uri; +import android.os.AsyncTask; import com.android.camera.filmstrip.DataAdapter; +import com.android.camera.widget.Preloader; import java.util.List; @@ -26,7 +28,8 @@ import java.util.List; * An interface which extends {@link com.android.camera.filmstrip.DataAdapter} * and defines operations on the data in the local camera folder. */ -public interface LocalDataAdapter extends DataAdapter { +public interface LocalDataAdapter extends DataAdapter, + Preloader.ItemLoader, Preloader.ItemSource { public interface LocalDataListener { /** @@ -127,8 +130,10 @@ public interface LocalDataAdapter extends DataAdapter { * {@link com.android.camera.data.LocalDataAdapter.LocalDataListener}. * * @param dataId The ID of the data to update the metadata for. + * @return An {@link android.os.AsyncTask} performing the background load + * that can be used to cancel the load if it's no longer needed. */ - public void updateMetadata(int dataId); + public AsyncTask updateMetadata(int dataId); /** * @return whether the metadata is already updated. diff --git a/src/com/android/camera/data/LocalDataUtil.java b/src/com/android/camera/data/LocalDataUtil.java index f2df1eeab..787711602 100644 --- a/src/com/android/camera/data/LocalDataUtil.java +++ b/src/com/android/camera/data/LocalDataUtil.java @@ -66,6 +66,7 @@ public class LocalDataUtil { BitmapFactory.decodeFile(path, justBoundsOpts); if (justBoundsOpts.outWidth > 0 && justBoundsOpts.outHeight > 0) { size.set(justBoundsOpts.outWidth, justBoundsOpts.outHeight); + } else { Log.e(TAG, "Bitmap dimension decoding failed for " + path); } return size; diff --git a/src/com/android/camera/data/LocalMediaData.java b/src/com/android/camera/data/LocalMediaData.java index 1c19dc563..62b44e84f 100644 --- a/src/com/android/camera/data/LocalMediaData.java +++ b/src/com/android/camera/data/LocalMediaData.java @@ -26,7 +26,7 @@ import android.graphics.BitmapFactory; import android.graphics.Point; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.media.MediaMetadataRetriever; +import android.media.CamcorderProfile; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; @@ -128,6 +128,8 @@ public abstract class LocalMediaData implements LocalData { + cursor.getString(dataIndex)); } } + + cursor.close(); } return result; } @@ -299,7 +301,7 @@ public abstract class LocalMediaData implements LocalData { @Override public boolean isMetadataUpdated() { - return MetadataLoader.isMetadataLoaded(this); + return MetadataLoader.isMetadataCached(this); } /** @@ -663,6 +665,44 @@ public abstract class LocalMediaData implements LocalData { new VideoDataBuilder()); } + /** + * We can't trust the media store and we can't afford the performance overhead of + * synchronously decoding the video header for every item when loading our data set + * from the media store, so we instead run the metadata loader in the background + * to decode the video header for each item and prefer whatever values it obtains. + */ + private int getBestWidth() { + int metadataWidth = VideoRotationMetadataLoader.getWidth(this); + if (metadataWidth > 0) { + return metadataWidth; + } else { + return mWidth; + } + } + + private int getBestHeight() { + int metadataHeight = VideoRotationMetadataLoader.getHeight(this); + if (metadataHeight > 0) { + return metadataHeight; + } else { + return mHeight; + } + } + + /** + * If the metadata loader has determined from the video header that we need to rotate the video + * 90 or 270 degrees, then we swap the width and height. + */ + @Override + public int getWidth() { + return VideoRotationMetadataLoader.isRotated(this) ? getBestHeight() : getBestWidth(); + } + + @Override + public int getHeight() { + return VideoRotationMetadataLoader.isRotated(this) ? getBestWidth() : getBestHeight(); + } + private static VideoData buildFromCursor(Cursor c) { long id = c.getLong(COL_ID); String title = c.getString(COL_TITLE); @@ -673,42 +713,15 @@ public abstract class LocalMediaData implements LocalData { int width = c.getInt(COL_WIDTH); int height = c.getInt(COL_HEIGHT); - // Extracts video height/width if available. If unavailable, set to - // 0. + // If the media store doesn't contain a width and a height, use the width and height + // of the default camera mode instead. When the metadata loader runs, it will set the + // correct values. if (width == 0 || height == 0) { - MediaMetadataRetriever retriever = new MediaMetadataRetriever(); - String rotation = null; - try { - retriever.setDataSource(path); - } catch (RuntimeException ex) { - // setDataSource() can cause RuntimeException beyond - // IllegalArgumentException. e.g: data contain *.avi file. - retriever.release(); - Log.e(TAG, "MediaMetadataRetriever.setDataSource() fail:" - + ex.getMessage()); - return null; - } - rotation = retriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION); - - String val = retriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH); - width = (val == null) ? 0 : Integer.parseInt(val); - val = retriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT); - height = (val == null) ? 0 : Integer.parseInt(val); - retriever.release(); - if (width == 0 || height == 0) { - // Width or height is still not available. - Log.e(TAG, "Unable to retrieve dimension of video:" + path); - return null; - } - if (rotation != null - && (rotation.equals("90") || rotation.equals("270"))) { - int b = width; - width = height; - height = b; - } + Log.w(TAG, "failed to retrieve width and height from the media store, defaulting " + + " to camera profile"); + CamcorderProfile profile = CamcorderProfile.get(CamcorderProfile.QUALITY_HIGH); + width = profile.videoFrameWidth; + height = profile.videoFrameHeight; } long sizeInBytes = c.getLong(COL_SIZE); diff --git a/src/com/android/camera/data/MetadataLoader.java b/src/com/android/camera/data/MetadataLoader.java index 35bb88f4a..311f5cfd7 100644 --- a/src/com/android/camera/data/MetadataLoader.java +++ b/src/com/android/camera/data/MetadataLoader.java @@ -24,16 +24,34 @@ import android.content.Context; */ class MetadataLoader { - private static final String KEY_METADATA_UPDATED = "metadata_updated"; + private static final String KEY_METADATA_CACHED = "metadata_cached"; - static void loadMetadata(final Context context, final LocalData data) { - PanoramaMetadataLoader.loadPanoramaMetadata(context, data.getUri(), - data.getMetadata()); - RgbzMetadataLoader.loadRgbzMetadata(context, data.getUri(), data.getMetadata()); - data.getMetadata().putBoolean(MetadataLoader.KEY_METADATA_UPDATED, true); + /** + * Adds information to the data's metadata bundle if any is available and returns + * true if metadata was added and false otherwise. In either case, sets + * a flag indicating that we've cached any available metadata and don't need to + * load metadata again for this particular item. + * + * @param context A context. + * @param data The data to update metadata for. + * @return true if any metadata was added to the data, false otherwise. + */ + static boolean loadMetadata(final Context context, final LocalData data) { + boolean metadataAdded = false; + if (data.getLocalDataType() == LocalData.LOCAL_IMAGE) { + PanoramaMetadataLoader.loadPanoramaMetadata(context, data.getUri(), + data.getMetadata()); + RgbzMetadataLoader.loadRgbzMetadata(context, data.getUri(), data.getMetadata()); + metadataAdded = true; + } else if (data.getLocalDataType() == LocalData.LOCAL_VIDEO) { + VideoRotationMetadataLoader.loadRotationMetdata(data); + metadataAdded = true; + } + data.getMetadata().putBoolean(MetadataLoader.KEY_METADATA_CACHED, true); + return metadataAdded; } - static boolean isMetadataLoaded(final LocalData data) { - return data.getMetadata().getBoolean(MetadataLoader.KEY_METADATA_UPDATED); + static boolean isMetadataCached(final LocalData data) { + return data.getMetadata().getBoolean(MetadataLoader.KEY_METADATA_CACHED); } } diff --git a/src/com/android/camera/data/VideoRotationMetadataLoader.java b/src/com/android/camera/data/VideoRotationMetadataLoader.java new file mode 100644 index 000000000..608ceee70 --- /dev/null +++ b/src/com/android/camera/data/VideoRotationMetadataLoader.java @@ -0,0 +1,55 @@ +package com.android.camera.data; + +import android.media.MediaMetadataRetriever; +import android.util.Log; + +public class VideoRotationMetadataLoader { + private static final String TAG = "VideoRotationMetadataLoader"; + private static final String ROTATION_KEY = "metadata_video_rotation"; + private static final String WIDTH_KEY = "metadata_video_width"; + private static final String HEIGHT_KEY = "metadata_video_height"; + + private static final String ROTATE_90 = "90"; + private static final String ROTATE_270 = "270"; + + static boolean isRotated(LocalData localData) { + final String rotation = localData.getMetadata().getString(ROTATION_KEY); + return ROTATE_90.equals(rotation) || ROTATE_270.equals(rotation); + } + + static int getWidth(LocalData localData) { + return localData.getMetadata().getInt(WIDTH_KEY); + + } + + static int getHeight(LocalData localData) { + return localData.getMetadata().getInt(HEIGHT_KEY); + } + + static void loadRotationMetdata(final LocalData data) { + final String path = data.getPath(); + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + try { + retriever.setDataSource(path); + String rotation = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION); + data.getMetadata().putString(ROTATION_KEY, rotation); + + String val = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH); + int width = Integer.parseInt(val); + + data.getMetadata().putInt(WIDTH_KEY, width); + + val = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT); + int height = Integer.parseInt(val); + + data.getMetadata().putInt(HEIGHT_KEY, height); + } catch (RuntimeException ex) { + // setDataSource() can cause RuntimeException beyond + // IllegalArgumentException. e.g: data contain *.avi file. + Log.e(TAG, "MediaMetdataRetriever.setDataSource() fail", ex); + } + } +} diff --git a/src/com/android/camera/filmstrip/FilmstripController.java b/src/com/android/camera/filmstrip/FilmstripController.java index 46ba12b05..e3a6484cd 100644 --- a/src/com/android/camera/filmstrip/FilmstripController.java +++ b/src/com/android/camera/filmstrip/FilmstripController.java @@ -250,5 +250,15 @@ public interface FilmstripController { * @param newDataId The ID of the focused data of {@code -1} if none. */ public void onDataFocusChanged(int prevDataId, int newDataId); + + /** + * The callback when we scroll. + * + * @param firstVisiblePosition The position of the first rendered item (may be slightly offscreen depending on + * the orientation of the device). + * @param visibleItemCount The total number of rendered items. + * @param totalItemCount The total number of items in the filmstrip. + */ + public void onScroll(int firstVisiblePosition, int visibleItemCount, int totalItemCount); } } diff --git a/src/com/android/camera/widget/FilmstripView.java b/src/com/android/camera/widget/FilmstripView.java index c305bba29..2fec90cbc 100644 --- a/src/com/android/camera/widget/FilmstripView.java +++ b/src/com/android/camera/widget/FilmstripView.java @@ -797,6 +797,10 @@ public class FilmstripView extends ViewGroup { invalidate(); if (mListener != null) { mListener.onDataFocusChanged(prevDataId, mViewItem[mCurrentItem].getId()); + final int firstVisible = mViewItem[mCurrentItem].getId() - 2; + final int visibleItemCount = firstVisible + BUFFER_SIZE; + final int totalItemCount = mDataAdapter.getTotalNumber(); + mListener.onScroll(firstVisible, visibleItemCount, totalItemCount); } } diff --git a/src/com/android/camera/widget/Preloader.java b/src/com/android/camera/widget/Preloader.java new file mode 100644 index 000000000..75b9eabd2 --- /dev/null +++ b/src/com/android/camera/widget/Preloader.java @@ -0,0 +1,174 @@ +package com.android.camera.widget; + +import android.util.Log; +import android.widget.AbsListView; + +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Responsible for controlling preloading logic. Intended usage is for ListViews that + * benefit from initiating a load before the row appear on screen. + * @param The type of items this class preload. + * @param The type of load tokens that can be used to cancel loads for the items this class + * preloads. + */ +public class Preloader implements AbsListView.OnScrollListener { + private static final String TAG = "Preloader"; + + /** + * Implemented by the source for items that should be preloaded. + */ + public interface ItemSource { + /** + * Returns the objects in the range [startPosition; endPosition). + */ + public List getItemsInRange(int startPosition, int endPosition); + + /** + * Returns the total number of items in the source. + */ + public int getCount(); + } + + /** + * Responsible for the loading of items. + */ + public interface ItemLoader { + /** + * Initiates a load for the specified items and returns a list of 0 or more load tokens that + * can be used to cancel the loads for the given items. Should preload the items in the list + * order,preloading the 0th item in the list fist. + */ + public List preloadItems(List items); + + /** + * Cancels all of the loads represented by the given load tokens. + */ + public void cancelItems(List loadTokens); + } + + private final int mMaxConcurrentPreloads; + + /** + * Keep track of the largest/smallest item we requested (depending on scroll direction) so + * we don't preload the same items repeatedly. Without this var, scrolling down we preload + * 0-5, then 1-6 etc. Using this we instead preload 0-5, then 5-6, 6-7 etc. + */ + private int mLastEnd = -1; + private int mLastStart; + + private final int mLoadAheadItems; + private ItemSource mItemSource; + private ItemLoader mItemLoader; + private Queue> mItemLoadTokens = new LinkedBlockingQueue>(); + + private int mLastVisibleItem; + private boolean mScrollingDown = false; + + public Preloader(int loadAheadItems, ItemSource itemSource, ItemLoader itemLoader) { + mItemSource = itemSource; + mItemLoader = itemLoader; + mLoadAheadItems = loadAheadItems; + // Add an additional item so we don't cancel a preload before we start a real load. + mMaxConcurrentPreloads = loadAheadItems + 1; + } + + /** + * Initiates a pre load. + * + * @param first The source position to load from + * @param increasing The direction we're going in (increasing -> source positions are + * increasing -> we're scrolling down the list) + */ + private void preload(int first, boolean increasing) { + final int start; + final int end; + if (increasing) { + start = Math.max(first, mLastEnd); + end = Math.min(first + mLoadAheadItems, mItemSource.getCount()); + } else { + start = Math.max(0, first - mLoadAheadItems); + end = Math.min(first, mLastStart); + } + + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "preload first=" + first + " increasing=" + increasing + " start=" + start + + " end=" + end); + } + + mLastEnd = end; + mLastStart = start; + + if (start == 0 && end == 0) { + return; + } + + final List items = mItemSource.getItemsInRange(start, end); + if (!increasing) { + Collections.reverse(items); + } + registerLoadTokens(mItemLoader.preloadItems(items)); + } + + private void registerLoadTokens(List loadTokens) { + mItemLoadTokens.offer(loadTokens); + // We pretend that one batch of load tokens corresponds to one item in the list. This isn't + // strictly true because we may batch preload multiple items at once when we first start + // scrolling in the list or change the direction we're scrolling in. In those cases, we will + // have a single large batch of load tokens for multiple items, and then go back to getting + // one batch per item as we continue to scroll. This means we may not cancel as many + // preloads as we expect when we change direction, but we can at least be sure we won't + // cancel preloads for items we still care about. We can't be more precise here because + // there is no guarantee that there is a one to one relationship between load tokens + // and list items. + if (mItemLoadTokens.size() > mMaxConcurrentPreloads) { + final List loadTokensToCancel = mItemLoadTokens.poll(); + mItemLoader.cancelItems(loadTokensToCancel); + } + } + + public void cancelAllLoads() { + for (List loadTokens : mItemLoadTokens) { + mItemLoader.cancelItems(loadTokens); + } + mItemLoadTokens.clear(); + } + + @Override + public void onScrollStateChanged(AbsListView absListView, int i) { + // Do nothing. + } + + @Override + public void onScroll(AbsListView absListView, int firstVisible, int visibleItemCount, + int totalItemCount) { + boolean wasScrollingDown = mScrollingDown; + int preloadStart = -1; + if (firstVisible > mLastVisibleItem) { + // Scrolling list down + mScrollingDown = true; + preloadStart = firstVisible + visibleItemCount; + } else if (firstVisible < mLastVisibleItem) { + // Scrolling list Up + mScrollingDown = false; + preloadStart = firstVisible; + } + + if (wasScrollingDown != mScrollingDown) { + // If we've changed directions, we don't care about any of our old preloads, so cancel + // all of them. + cancelAllLoads(); + } + + // onScroll can be called multiple times with the same arguments, so we only want to preload + // if we've actually scrolled at least an item in either direction. + if (preloadStart != -1) { + preload(preloadStart, mScrollingDown); + } + + mLastVisibleItem = firstVisible; + } +} -- 2.11.0