am 88d80f44: story mode for PhotoTable. also some cleanup and refactoring also fix stuck alphas

* commit '88d80f4471c900628e2cb6eef23029b99af48e09':
  story mode for PhotoTable. also some cleanup and refactoring also fix stuck alphas
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 59a2236..c401ef7 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -6,6 +6,7 @@
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.WAKE_LOCK" />
   <uses-permission android:name="com.google.android.gallery3d.permission.PICASA_STORE" />
+  <uses-sdk android:minSdkVersion="17" android:targetSdkVersion="17"/>
 
   <application
       android:label="@string/app_name"
diff --git a/res/values/config.xml b/res/values/config.xml
index 1c9de4a..e20ac68 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -97,5 +97,8 @@
   <!-- Parts per million gain applied to generalized touch gestures. -->
   <integer name="generalized_touch_gain">2000000</integer>
 
+  <!-- Enable story mode. -->
+  <bool name="enable_story_mode">true</bool>
+
 </resources>
 
diff --git a/src/com/android/dreams/phototable/AlbumDataAdapter.java b/src/com/android/dreams/phototable/AlbumDataAdapter.java
index 699fe14..570bbd7 100644
--- a/src/com/android/dreams/phototable/AlbumDataAdapter.java
+++ b/src/com/android/dreams/phototable/AlbumDataAdapter.java
@@ -17,7 +17,6 @@
 
 import android.content.Context;
 import android.content.SharedPreferences;
-import android.text.SpannableString;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -30,7 +29,6 @@
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 
 /**
  * Settings panel for photo flipping dream.
diff --git a/src/com/android/dreams/phototable/AlbumSettings.java b/src/com/android/dreams/phototable/AlbumSettings.java
index 069948b..1ccd498 100644
--- a/src/com/android/dreams/phototable/AlbumSettings.java
+++ b/src/com/android/dreams/phototable/AlbumSettings.java
@@ -51,7 +51,6 @@
 
     public boolean isAlbumEnabled(String albumId) {
         synchronized (mEnabledAlbums) {
-            boolean isEnabled = mEnabledAlbums.contains(albumId);
             return mEnabledAlbums.contains(albumId);
         }
     }
diff --git a/src/com/android/dreams/phototable/CursorPhotoSource.java b/src/com/android/dreams/phototable/CursorPhotoSource.java
new file mode 100644
index 0000000..cb4ce6b
--- /dev/null
+++ b/src/com/android/dreams/phototable/CursorPhotoSource.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2013 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.android.dreams.phototable;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+
+/**
+ * Common implementation for sources that load images from a cursor.
+ */
+public abstract class CursorPhotoSource extends PhotoSource {
+
+    // An invalid cursor position to represent the uninitialized state.
+    protected static final int UNINITIALIZED = -1;
+    // An invalid cursor position to represent the error state.
+    protected static final int INVALID = -2;
+
+    public CursorPhotoSource(Context context, SharedPreferences settings) {
+        super(context, settings);
+    }
+
+    public CursorPhotoSource(Context context, SharedPreferences settings, PhotoSource fallback) {
+      super(context, settings, fallback);
+    }
+
+    @Override
+    protected ImageData naturalNext(ImageData current) {
+        if (current.cursor == null) {
+            openCursor(current);
+            findPosition(current);
+        }
+        current.cursor.moveToPosition(current.position);
+        current.cursor.moveToNext();
+        ImageData data = null;
+        if (!current.cursor.isAfterLast()) {
+            data = unpackImageData(current.cursor, null);
+            data.cursor = current.cursor;
+            data.position = current.cursor.getPosition();
+        }
+        return data;
+    }
+
+    @Override
+    protected ImageData naturalPrevious(ImageData current) {
+        if (current.cursor == null) {
+            openCursor(current);
+            findPosition(current);
+        }
+        current.cursor.moveToPosition(current.position);
+        current.cursor.moveToPrevious();
+        ImageData data = null;
+        if (!current.cursor.isBeforeFirst()) {
+            data = unpackImageData(current.cursor, null);
+            data.cursor = current.cursor;
+            data.position = current.cursor.getPosition();
+        }
+        return data;
+    }
+
+    protected abstract void openCursor(ImageData data);
+    protected abstract void findPosition(ImageData data);
+    protected abstract ImageData unpackImageData(Cursor cursor, ImageData data);
+}
+
diff --git a/src/com/android/dreams/phototable/DragGestureDetector.java b/src/com/android/dreams/phototable/DragGestureDetector.java
index 739163d..2153c48 100644
--- a/src/com/android/dreams/phototable/DragGestureDetector.java
+++ b/src/com/android/dreams/phototable/DragGestureDetector.java
@@ -17,19 +17,18 @@
 
 import android.content.Context;
 import android.content.res.Resources;
-import android.util.Log;
 import android.view.MotionEvent;
 
 /**
  * Detect and dispatch edge events.
  */
 public class DragGestureDetector {
+    @SuppressWarnings("unused")
     private static final String TAG = "DragGestureDetector";
 
     private final PhotoTable mTable;
     private final float mTouchGain;
 
-    private int mPointer;
     private float[] mLast;
     private float[] mCurrent;
     private boolean mDrag;
@@ -66,7 +65,6 @@
         int index = event.getActionIndex();
         switch (event.getActionMasked()) {
             case MotionEvent.ACTION_DOWN:
-                mPointer = event.getPointerId(index);
                 computeAveragePosition(event, mLast);
                 mDrag = false;
                 break;
@@ -104,7 +102,7 @@
     }
 
     private void move(MotionEvent event, boolean drop) {
-        mTable.move(mTable.getFocused(),
+        mTable.move(mTable.getFocus(),
                 mTouchGain * (mCurrent[0] - mLast[0]),
                 mTouchGain * (mCurrent[1] - mLast[1]),
                 drop);
diff --git a/src/com/android/dreams/phototable/EdgeSwipeDetector.java b/src/com/android/dreams/phototable/EdgeSwipeDetector.java
index 98ad6c6..e5ca23d 100644
--- a/src/com/android/dreams/phototable/EdgeSwipeDetector.java
+++ b/src/com/android/dreams/phototable/EdgeSwipeDetector.java
@@ -23,6 +23,7 @@
  * Detect and dispatch edge events.
  */
 public class EdgeSwipeDetector {
+    @SuppressWarnings("unused")
     private static final String TAG = "EdgeSwipeDetector";
     private float mEdgeSwipeGutter;
     private float mEdgeSwipeThreshold;
@@ -61,7 +62,7 @@
                             * mEdgeSwipeThreshold;
                     if (event.getX() > enough) {
                         if (mTable.hasFocus()) {
-                            mTable.fling(mTable.getFocused());
+                            mTable.fling(mTable.getFocus());
                         } else if (mTable.hasSelection()) {
                             mTable.clearSelection();
                         }
diff --git a/src/com/android/dreams/phototable/FlipperDream.java b/src/com/android/dreams/phototable/FlipperDream.java
index 36d8c7b..b70c8d4 100644
--- a/src/com/android/dreams/phototable/FlipperDream.java
+++ b/src/com/android/dreams/phototable/FlipperDream.java
@@ -15,7 +15,6 @@
  */
 package com.android.dreams.phototable;
 
-import android.content.SharedPreferences;
 import android.service.dreams.DreamService;
 
 /**
diff --git a/src/com/android/dreams/phototable/FlipperDreamSettings.java b/src/com/android/dreams/phototable/FlipperDreamSettings.java
index 50e5f1e..87802c6 100644
--- a/src/com/android/dreams/phototable/FlipperDreamSettings.java
+++ b/src/com/android/dreams/phototable/FlipperDreamSettings.java
@@ -15,16 +15,15 @@
  */
 package com.android.dreams.phototable;
 
+import android.app.ListActivity;
 import android.content.SharedPreferences;
 import android.database.DataSetObserver;
-import android.app.ListActivity;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.View;
-import android.widget.ListAdapter;
 
 import java.util.LinkedList;
 
@@ -32,6 +31,7 @@
  * Settings panel for photo flipping dream.
  */
 public class FlipperDreamSettings extends ListActivity {
+    @SuppressWarnings("unused")
     private static final String TAG = "FlipperDreamSettings";
     public static final String PREFS_NAME = FlipperDream.TAG;
 
diff --git a/src/com/android/dreams/phototable/KeyboardInterpreter.java b/src/com/android/dreams/phototable/KeyboardInterpreter.java
new file mode 100644
index 0000000..aa316cb
--- /dev/null
+++ b/src/com/android/dreams/phototable/KeyboardInterpreter.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2013 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.android.dreams.phototable;
+
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+
+/**
+ * Keyboard event dispatcher for Photo Table.
+ */
+public class KeyboardInterpreter {
+    private static final String TAG = "DPadInterpreter";
+    private static final boolean DEBUG = false;
+
+    private final PhotoTable mTable;
+
+    public KeyboardInterpreter(PhotoTable table) {
+        mTable = table;
+    }
+
+    public boolean onKeyDown(int keyCode, KeyEvent event) {
+        final View focus = mTable.getFocus();
+        boolean consumed = true;
+
+        if (mTable.hasSelection()) {
+            switch (keyCode) {
+            case KeyEvent.KEYCODE_ENTER:
+            case KeyEvent.KEYCODE_DPAD_CENTER:
+            case KeyEvent.KEYCODE_ESCAPE:
+                mTable.setFocus(mTable.getSelection());
+                mTable.clearSelection();
+                break;
+            default:
+                if (DEBUG) Log.d(TAG, "dropped unexpected: " + keyCode);
+                consumed = false;
+                break;
+            }
+        } else {
+            switch (keyCode) {
+            case KeyEvent.KEYCODE_ENTER:
+            case KeyEvent.KEYCODE_DPAD_CENTER:
+                if (mTable.hasFocus()) {
+                    mTable.setSelection(mTable.getFocus());
+                    mTable.clearFocus();
+                } else {
+                    mTable.setDefaultFocus();
+                }
+                break;
+
+            case KeyEvent.KEYCODE_DEL:
+            case KeyEvent.KEYCODE_X:
+                if (mTable.hasFocus()) {
+                    mTable.fling(mTable.getFocus());
+                }
+                break;
+
+            case KeyEvent.KEYCODE_DPAD_UP:
+            case KeyEvent.KEYCODE_K:
+                mTable.moveFocus(focus, 0f);
+                break;
+
+            case KeyEvent.KEYCODE_DPAD_RIGHT:
+            case KeyEvent.KEYCODE_L:
+                mTable.moveFocus(focus, 90f);
+                break;
+
+            case KeyEvent.KEYCODE_DPAD_DOWN:
+            case KeyEvent.KEYCODE_J:
+                mTable.moveFocus(focus, 180f);
+                break;
+
+            case KeyEvent.KEYCODE_DPAD_LEFT:
+            case KeyEvent.KEYCODE_H:
+                mTable.moveFocus(focus, 270f);
+                break;
+
+            default:
+                if (DEBUG) Log.d(TAG, "dropped unexpected: " + keyCode);
+                consumed = false;
+                break;
+            }
+        }
+
+        return consumed;
+    }
+}
diff --git a/src/com/android/dreams/phototable/LocalSource.java b/src/com/android/dreams/phototable/LocalSource.java
index 4efc43b..cf2e0ec 100644
--- a/src/com/android/dreams/phototable/LocalSource.java
+++ b/src/com/android/dreams/phototable/LocalSource.java
@@ -30,7 +30,7 @@
 /**
  * Loads images from the local store.
  */
-public class LocalSource extends PhotoSource {
+public class LocalSource extends CursorPhotoSource {
     private static final String TAG = "PhotoTable.LocalSource";
 
     private final String mUnknownAlbumName;
@@ -113,6 +113,59 @@
     }
 
     @Override
+    protected void openCursor(ImageData data) {
+        log(TAG, "opening single album");
+
+        String[] projection = {MediaStore.Images.Media.DATA, MediaStore.Images.Media.ORIENTATION,
+                MediaStore.Images.Media.BUCKET_ID, MediaStore.Images.Media.BUCKET_DISPLAY_NAME};
+        String selection = MediaStore.Images.Media.BUCKET_ID + " = '" + data.albumId + "'";
+
+        data.cursor = mResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+                projection, selection, null, null);
+    }
+
+    @Override
+    protected void findPosition(ImageData data) {
+        if (data.position == -1) {
+            if (data.cursor == null) {
+                openCursor(data);
+            }
+            if (data.cursor != null) {
+                int dataIndex = data.cursor.getColumnIndex(MediaStore.Images.Media.DATA);
+                data.cursor.moveToPosition(-1);
+                while (data.position == -1 && data.cursor.moveToNext()) {
+                    String url = data.cursor.getString(dataIndex);
+                    if (url != null && url.equals(data.url)) {
+                        data.position = data.cursor.getPosition();
+                    }
+                }
+                if (data.position == -1) {
+                    // oops!  The image isn't in this album. How did we get here?
+                    data.position = INVALID;
+                }
+            }
+        }
+    }
+
+    @Override
+    protected ImageData unpackImageData(Cursor cursor, ImageData data) {
+        if (data == null) {
+            data = new ImageData();
+        }
+        int dataIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
+        int orientationIndex = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION);
+        int bucketIndex = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID);
+
+        data.url = cursor.getString(dataIndex);
+        data.albumId = cursor.getString(bucketIndex);
+        data.position = UNINITIALIZED;
+        data.cursor = null;
+        data.orientation = cursor.getInt(orientationIndex);
+
+        return data;
+    }
+
+    @Override
     protected Collection<ImageData> findImages(int howMany) {
         log(TAG, "finding images");
         LinkedList<ImageData> foundImages = new LinkedList<ImageData>();
@@ -138,23 +191,18 @@
         Cursor cursor = mResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                 projection, selection, null, null);
         if (cursor != null) {
+            int dataIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
+
             if (cursor.getCount() > howMany && mLastPosition == INVALID) {
                 mLastPosition = pickRandomStart(cursor.getCount(), howMany);
             }
             cursor.moveToPosition(mLastPosition);
 
-            int dataIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
-            int orientationIndex = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION);
-            int bucketIndex = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID);
-            int nameIndex = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME);
-
             if (dataIndex < 0) {
                 log(TAG, "can't find the DATA column!");
             } else {
                 while (foundImages.size() < howMany && cursor.moveToNext()) {
-                    ImageData data = new ImageData();
-                    data.url = cursor.getString(dataIndex);
-                    data.orientation = cursor.getInt(orientationIndex);
+                    ImageData data = unpackImageData(cursor, null);
                     foundImages.offer(data);
                     mLastPosition = cursor.getPosition();
                 }
@@ -186,3 +234,4 @@
         return (InputStream) fis;
     }
 }
+
diff --git a/src/com/android/dreams/phototable/PhotoCarousel.java b/src/com/android/dreams/phototable/PhotoCarousel.java
index 536d76b..9e77d06 100644
--- a/src/com/android/dreams/phototable/PhotoCarousel.java
+++ b/src/com/android/dreams/phototable/PhotoCarousel.java
@@ -20,7 +20,6 @@
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.os.AsyncTask;
-import android.service.dreams.DreamService;
 import android.util.AttributeSet;
 import android.util.Log;
 import android.view.GestureDetector;
@@ -191,9 +190,9 @@
             int orientation = (width > height ? LANDSCAPE : PORTRAIT);
 
             destination.setImageBitmap(photo);
-            destination.setTag(R.id.photo_orientation, new Integer(orientation));
-            destination.setTag(R.id.photo_width, new Integer(width));
-            destination.setTag(R.id.photo_height, new Integer(height));
+            destination.setTag(R.id.photo_orientation, Integer.valueOf(orientation));
+            destination.setTag(R.id.photo_width, Integer.valueOf(width));
+            destination.setTag(R.id.photo_height, Integer.valueOf(height));
             setScaleType(destination);
 
             mBitmapStore.put(destination, photo);
@@ -249,8 +248,8 @@
         frontA = 1f - frontA;
         backA = 1f - backA;
 
-	// Don't rotate
-	frontY = backY = 0f;
+        // Don't rotate
+        frontY = backY = 0f;
 
         ViewPropertyAnimator frontAnim = mPanel[0].animate()
                 .rotationY(frontY)
@@ -286,7 +285,6 @@
 
         mOrientation = (mWidth > mHeight ? LANDSCAPE : PORTRAIT);
 
-        boolean init = mLongSide == 0;
         mLongSide = (int) Math.max(mWidth, mHeight);
         mShortSide = (int) Math.min(mWidth, mHeight);
 
diff --git a/src/com/android/dreams/phototable/PhotoSource.java b/src/com/android/dreams/phototable/PhotoSource.java
index 32d41c7..d9d4ab9 100644
--- a/src/com/android/dreams/phototable/PhotoSource.java
+++ b/src/com/android/dreams/phototable/PhotoSource.java
@@ -23,16 +23,15 @@
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Matrix;
-import android.net.Uri;
-import android.provider.MediaStore;
 import android.util.Log;
 
-import java.io.FileNotFoundException;
-import java.io.InputStream;
-import java.io.IOException;
 import java.io.BufferedInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.Random;
 
@@ -43,9 +42,6 @@
     private static final String TAG = "PhotoTable.PhotoSource";
     private static final boolean DEBUG = false;
 
-    // An invalid cursor position to represent the uninitialized state.
-    protected static final int INVALID = -2;
-
     // This should be large enough for BitmapFactory to decode the header so
     // that we can mark and reset the input stream to avoid duplicate network i/o
     private static final int BUFFER_SIZE = 128 * 1024;
@@ -55,9 +51,19 @@
         public String url;
         public int orientation;
 
+        protected String albumId;
+        protected Cursor cursor;
+        protected int position;
+
         InputStream getStream(int longSide) {
             return PhotoSource.this.getStream(this, longSide);
         }
+        ImageData naturalNext() {
+            return PhotoSource.this.naturalNext(this);
+        }
+        ImageData naturalPrevious() {
+            return PhotoSource.this.naturalPrevious(this);
+        }
     }
 
     public class AlbumData {
@@ -79,6 +85,7 @@
     private final float mMaxCropRatio;
     private final int mBadImageSkipLimit;
     private final PhotoSource mFallbackSource;
+    private final HashMap<Bitmap, ImageData> mImageMap;
 
     protected final Context mContext;
     protected final Resources mResources;
@@ -102,6 +109,7 @@
         mMaxQueueSize = mResources.getInteger(R.integer.image_queue_size);
         mMaxCropRatio = mResources.getInteger(R.integer.max_crop_ratio) / 1000000f;
         mBadImageSkipLimit = mResources.getInteger(R.integer.bad_image_skip_limit);
+        mImageMap = new HashMap<Bitmap, ImageData>();
         mRNG = new Random();
         mFallbackSource = fallbackSource;
     }
@@ -124,11 +132,11 @@
                 if (mImageQueue.isEmpty()) {
                     fillQueue();
                 }
-
                 imageData = mImageQueue.poll();
             }
             if (imageData != null) {
                 image = load(imageData, options, longSide, shortSide);
+                mImageMap.put(image, imageData);
                 imageData = null;
             }
 
@@ -259,7 +267,38 @@
         }
     }
 
+    public Bitmap naturalNext(Bitmap current, BitmapFactory.Options options,
+            int longSide, int shortSide) {
+        Bitmap image = null;
+        ImageData data = mImageMap.get(current);
+        if (data != null) {
+          ImageData next = data.naturalNext();
+          if (next != null) {
+            image = load(next, options, longSide, shortSide);
+            mImageMap.put(image, next);
+          }
+        }
+        return image;
+    }
+
+    public Bitmap naturalPrevious(Bitmap current, BitmapFactory.Options options,
+            int longSide, int shortSide) {
+        Bitmap image = null;
+        ImageData data = mImageMap.get(current);
+        if (current != null) {
+          ImageData prev = data.naturalPrevious();
+          if (prev != null) {
+            image = load(prev, options, longSide, shortSide);
+            mImageMap.put(image, prev);
+          }
+        }
+        return image;
+    }
+
     protected abstract InputStream getStream(ImageData data, int longSide);
     protected abstract Collection<ImageData> findImages(int howMany);
+    protected abstract ImageData naturalNext(ImageData current);
+    protected abstract ImageData naturalPrevious(ImageData current);
+
     public abstract Collection<AlbumData> findAlbums();
 }
diff --git a/src/com/android/dreams/phototable/PhotoSourcePlexor.java b/src/com/android/dreams/phototable/PhotoSourcePlexor.java
index 147f16e..93fdc9e 100644
--- a/src/com/android/dreams/phototable/PhotoSourcePlexor.java
+++ b/src/com/android/dreams/phototable/PhotoSourcePlexor.java
@@ -17,12 +17,8 @@
 
 import android.content.Context;
 import android.content.SharedPreferences;
-import android.database.Cursor;
-import android.net.Uri;
 
-import java.io.FileNotFoundException;
 import java.io.InputStream;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.LinkedList;
 
@@ -34,7 +30,6 @@
 
     private final PhotoSource mPicasaSource;
     private final PhotoSource mLocalSource;
-    private SharedPreferences mSettings;
 
     public PhotoSourcePlexor(Context context, SharedPreferences settings) {
         super(context, settings);
@@ -75,4 +70,14 @@
     protected InputStream getStream(ImageData data, int longSide) {
         return data.getStream(longSide);
     }
+
+    @Override
+    protected ImageData naturalNext(ImageData current) {
+        return current.naturalNext();
+    }
+
+    @Override
+    protected ImageData naturalPrevious(ImageData current) {
+        return current.naturalPrevious();
+    }
 }
diff --git a/src/com/android/dreams/phototable/PhotoTable.java b/src/com/android/dreams/phototable/PhotoTable.java
index 4cf278c..7d2f6b6 100644
--- a/src/com/android/dreams/phototable/PhotoTable.java
+++ b/src/com/android/dreams/phototable/PhotoTable.java
@@ -15,13 +15,10 @@
  */
 package com.android.dreams.phototable;
 
-import android.service.dreams.DreamService;
 import android.content.Context;
-import android.content.SharedPreferences;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
-import android.graphics.Color;
 import android.graphics.PointF;
 import android.graphics.PorterDuff;
 import android.graphics.Rect;
@@ -29,6 +26,7 @@
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.LayerDrawable;
 import android.os.AsyncTask;
+import android.service.dreams.DreamService;
 import android.util.AttributeSet;
 import android.util.Log;
 import android.view.KeyEvent;
@@ -39,8 +37,9 @@
 import android.view.animation.DecelerateInterpolator;
 import android.view.animation.Interpolator;
 import android.widget.FrameLayout;
-import android.widget.FrameLayout.LayoutParams;
 import android.widget.ImageView;
+
+import java.util.Formatter;
 import java.util.LinkedList;
 import java.util.Random;
 
@@ -68,8 +67,8 @@
 
     private static final int MAX_SELECTION_TIME = 10000;
     private static final int MAX_FOCUS_TIME = 5000;
-    private static final float EDGE_SWIPE_GUTTER = 0.05f;
-    private static final float EDGE_SWIPE_THRESHOLD = 0.25f;
+    private static final int NEXT = 1;
+    private static final int PREV = 0;
     private static Random sRNG = new Random();
 
     private final Launcher mLauncher;
@@ -91,20 +90,23 @@
     private final Resources mResources;
     private final Interpolator mThrowInterpolator;
     private final Interpolator mDropInterpolator;
-    final private EdgeSwipeDetector mEdgeSwipeDetector;
-    final private DragGestureDetector mDragGestureDetector;
+    private final DragGestureDetector mDragGestureDetector;
+    private final EdgeSwipeDetector mEdgeSwipeDetector;
+    private final KeyboardInterpreter mKeyboardInterpreter;
+    private final boolean mStoryModeEnabled;
     private DreamService mDream;
     private PhotoLaunchTask mPhotoLaunchTask;
+    private LoadNaturalSiblingTask mLoadOnDeckTasks[];
     private boolean mStarted;
     private boolean mIsLandscape;
     private int mLongSide;
     private int mShortSide;
     private int mWidth;
     private int mHeight;
-    private View mSelected;
-    private long mSelectedTime;
-    private View mFocused;
-    private long mFocusedTime;
+    private View mSelection;
+    private View mOnDeck[];
+    private long mSelectionTime;
+    private View mFocus;
     private int mHighlightColor;
 
     public PhotoTable(Context context, AttributeSet as) {
@@ -122,6 +124,7 @@
         mTableCapacity = mResources.getInteger(R.integer.table_capacity);
         mRedealCount = mResources.getInteger(R.integer.redeal_count);
         mTapToExit = mResources.getBoolean(R.bool.enable_tap_to_exit);
+        mStoryModeEnabled = mResources.getBoolean(R.bool.enable_story_mode);
         mHighlightColor = mResources.getColor(R.color.highlight_color);
         mThrowInterpolator = new SoftLandingInterpolator(
                 mResources.getInteger(R.integer.soft_landing_time) / 1000000f,
@@ -133,8 +136,11 @@
                 getContext().getSharedPreferences(PhotoTableDreamSettings.PREFS_NAME, 0));
         mLauncher = new Launcher();
         mFocusReaper = new FocusReaper();
-        mEdgeSwipeDetector = new EdgeSwipeDetector(context, this);
         mDragGestureDetector = new DragGestureDetector(context, this);
+        mEdgeSwipeDetector = new EdgeSwipeDetector(context, this);
+        mKeyboardInterpreter = new KeyboardInterpreter(this);
+        mLoadOnDeckTasks = new LoadNaturalSiblingTask[2];
+        mOnDeck = new View[2];
         mStarted = false;
     }
 
@@ -144,49 +150,107 @@
     }
 
     public boolean hasSelection() {
-        return mSelected != null;
+        return mSelection != null;
     }
 
-    public View getSelected() {
-        return mSelected;
+    public View getSelection() {
+        return mSelection;
     }
 
     public void clearSelection() {
         if (hasSelection()) {
-            dropOnTable(getSelected());
+            dropOnTable(getSelection());
         }
-        mSelected = null;
+        for (int slot = 0; slot < mOnDeck.length; slot++) {
+            if (mOnDeck[slot] != null) {
+                fadeAway(mOnDeck[slot], false);
+                mOnDeck[slot] = null;
+            }
+        }
+        mSelection = null;
     }
 
     public void setSelection(View selected) {
-        assert(selected != null);
-        clearSelection();
-        mSelected = selected;
-        mSelectedTime = System.currentTimeMillis();
-        moveToTopOfPile(selected);
-        pickUp(selected);
+        if (selected != null) {
+            clearSelection();
+            mSelection = selected;
+            promoteSelection();
+        }
+    }
+
+    public void selectNext() {
+        if (mStoryModeEnabled) {
+            log("selectNext");
+            if (hasSelection() && mOnDeck[NEXT] != null) {
+                placeOnDeck(mSelection, PREV);
+                mSelection = mOnDeck[NEXT];
+                mOnDeck[NEXT] = null;
+                promoteSelection();
+            }
+        } else {
+            clearSelection();
+        }
+    }
+
+    public void selectPrevious() {
+        if (mStoryModeEnabled) {
+            log("selectPrevious");
+            if (hasSelection() && mOnDeck[PREV] != null) {
+                placeOnDeck(mSelection, NEXT);
+                mSelection = mOnDeck[PREV];
+                mOnDeck[PREV] = null;
+                promoteSelection();
+            }
+        } else {
+            clearSelection();
+        }
+    }
+
+    private void promoteSelection() {
+        if (hasSelection()) {
+            mSelectionTime = System.currentTimeMillis();
+            mSelection.animate().cancel();
+            mSelection.setAlpha(1f);
+            moveToTopOfPile(mSelection);
+            pickUp(mSelection);
+            if (mStoryModeEnabled) {
+                for (int slot = 0; slot < mOnDeck.length; slot++) {
+                    if (mLoadOnDeckTasks[slot] != null &&
+                            mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) {
+                        mLoadOnDeckTasks[slot].cancel(true);
+                    }
+                    if (mOnDeck[slot] == null) {
+                        mLoadOnDeckTasks[slot] = new LoadNaturalSiblingTask(slot);
+                        mLoadOnDeckTasks[slot].execute(mSelection);
+                    }
+                }
+            }
+        }
     }
 
     public boolean hasFocus() {
-        return mFocused != null;
+        return mFocus != null;
     }
 
-    public View getFocused() {
-        return mFocused;
+    public View getFocus() {
+        return mFocus;
     }
 
     public void clearFocus() {
         if (hasFocus()) {
-            setHighlight(getFocused(), false);
+            setHighlight(getFocus(), false);
         }
-        mFocused = null;
+        mFocus = null;
+    }
+
+    public void setDefaultFocus() {
+        setFocus(mOnTable.getLast());
     }
 
     public void setFocus(View focus) {
         assert(focus != null);
         clearFocus();
-        mFocused = focus;
-        mFocusedTime = System.currentTimeMillis();
+        mFocus = focus;
         moveToTopOfPile(focus);
         setHighlight(focus, true);
         scheduleFocusReaper(MAX_FOCUS_TIME);
@@ -214,17 +278,8 @@
         return p;
     }
 
-    private static PointF randInCenter(float i, float j, int width, int height) {
-        log("randInCenter (" + i + ", " + j + ", " + width + ", " + height + ")");
-        PointF p = new PointF();
-        p.x = 0.5f * width + 0.15f * width * i;
-        p.y = 0.5f * height + 0.15f * height * j;
-        log("randInCenter returning " + p.x + "," + p.y);
-        return p;
-    }
-
     private static PointF randMultiDrop(int n, float i, float j, int width, int height) {
-        log("randMultiDrop (" + n + "," + i + ", " + j + ", " + width + ", " + height + ")");
+        log("randMultiDrop (%d, %f, %f, %d, %d)", n, i, j, width, height);
         final float[] cx = {0.3f, 0.3f, 0.5f, 0.7f, 0.7f};
         final float[] cy = {0.3f, 0.7f, 0.5f, 0.3f, 0.7f};
         n = Math.abs(n);
@@ -233,7 +288,7 @@
         PointF p = new PointF();
         p.x = x * width + 0.05f * width * i;
         p.y = y * height + 0.05f * height * j;
-        log("randInCenter returning " + p.x + "," + p.y);
+        log("randInCenter returning %f, %f", p.x, p.y);
         return p;
     }
 
@@ -241,10 +296,6 @@
         return a[0] * b[1] - a[1] * b[0];
     }
 
-    private double dot(double[] a, double[] b) {
-        return a[0] * b[0] + a[1] * b[1];
-    }
-
     private double norm(double[] a) {
         return Math.hypot(a[0], a[1]);
     }
@@ -296,74 +347,12 @@
                 setFocus(bestFocus);
             }
         }
-        return getFocused();
+        return getFocus();
     }
 
     @Override
     public boolean onKeyDown(int keyCode, KeyEvent event) {
-        final View focus = getFocused();
-        boolean consumed = true;
-
-        if (hasSelection()) {
-            switch (keyCode) {
-            case KeyEvent.KEYCODE_ENTER:
-            case KeyEvent.KEYCODE_DPAD_CENTER:
-            case KeyEvent.KEYCODE_ESCAPE:
-                setFocus(getSelected());
-                clearSelection();
-                break;
-            default:
-                log("dropped unexpected: " + keyCode);
-                consumed = false;
-                break;
-            }
-        } else {
-            switch (keyCode) {
-            case KeyEvent.KEYCODE_ENTER:
-            case KeyEvent.KEYCODE_DPAD_CENTER:
-                if (hasFocus()) {
-                    setSelection(getFocused());
-                    clearFocus();
-                } else {
-                    setFocus(mOnTable.getLast());
-                }
-                break;
-
-            case KeyEvent.KEYCODE_DEL:
-            case KeyEvent.KEYCODE_X:
-                if (hasFocus()) {
-                    fling(getFocused());
-                }
-                break;
-
-            case KeyEvent.KEYCODE_DPAD_UP:
-            case KeyEvent.KEYCODE_K:
-                moveFocus(focus, 0f);
-                break;
-
-            case KeyEvent.KEYCODE_DPAD_RIGHT:
-            case KeyEvent.KEYCODE_L:
-                moveFocus(focus, 90f);
-                break;
-
-            case KeyEvent.KEYCODE_DPAD_DOWN:
-            case KeyEvent.KEYCODE_J:
-                moveFocus(focus, 180f);
-                break;
-
-            case KeyEvent.KEYCODE_DPAD_LEFT:
-            case KeyEvent.KEYCODE_H:
-                moveFocus(focus, 270f);
-                break;
-
-            default:
-                log("dropped unexpected: " + keyCode);
-                consumed = false;
-                break;
-            }
-        }
-
-        return consumed;
+        return mKeyboardInterpreter.onKeyDown(keyCode, event);
     }
 
     @Override
@@ -389,7 +378,7 @@
     @Override
     public void onLayout(boolean changed, int left, int top, int right, int bottom) {
         super.onLayout(changed, left, top, right, bottom);
-        log("onLayout (" + left + ", " + top + ", " + right + ", " + bottom + ")");
+        log("onLayout (%d, %d, %d, %d)", left, top, right, bottom);
 
         mHeight = bottom - top;
         mWidth = right - left;
@@ -400,12 +389,18 @@
         boolean isLandscape = mWidth > mHeight;
         if (mIsLandscape != isLandscape) {
             for (View photo: mOnTable) {
-                if (photo == getSelected()) {
-                    pickUp(photo);
-                } else {
+                if (photo != getSelection()) {
                     dropOnTable(photo);
                 }
             }
+            if (hasSelection()) {
+                pickUp(getSelection());
+                for (int slot = 0; slot < mOnDeck.length; slot++) {
+                    if (mOnDeck[slot] != null) {
+                        placeOnDeck(mOnDeck[slot], slot);
+                    }
+                }
+            }
             mIsLandscape = isLandscape;
         }
         start();
@@ -416,6 +411,86 @@
         return true;
     }
 
+    /** Put a nice border on the bitmap. */
+    private static View applyFrame(final PhotoTable table, final BitmapFactory.Options options,
+            final Bitmap decodedPhoto) {
+        LayoutInflater inflater = (LayoutInflater) table.getContext()
+            .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+        View photo = inflater.inflate(R.layout.photo, null);
+        ImageView image = (ImageView) photo;
+        Drawable[] layers = new Drawable[2];
+        int photoWidth = options.outWidth;
+        int photoHeight = options.outHeight;
+        if (decodedPhoto == null || options.outWidth <= 0 || options.outHeight <= 0) {
+            photo = null;
+        } else {
+            decodedPhoto.setHasMipMap(true);
+            layers[0] = new BitmapDrawable(table.mResources, decodedPhoto);
+            layers[1] = table.mResources.getDrawable(R.drawable.frame);
+            LayerDrawable layerList = new LayerDrawable(layers);
+            layerList.setLayerInset(0, table.mInset, table.mInset,
+                                    table.mInset, table.mInset);
+            image.setImageDrawable(layerList);
+
+            photo.setTag(R.id.photo_width, Integer.valueOf(photoWidth));
+            photo.setTag(R.id.photo_height, Integer.valueOf(photoHeight));
+
+            photo.setOnTouchListener(new PhotoTouchListener(table.getContext(),
+                                                            table));
+        }
+        return photo;
+    }
+
+    private class LoadNaturalSiblingTask extends AsyncTask<View, Void, View> {
+        private final BitmapFactory.Options mOptions;
+        private final int mSlot;
+
+        public LoadNaturalSiblingTask (int slot) {
+            mOptions = new BitmapFactory.Options();
+            mOptions.inTempStorage = new byte[32768];
+            mSlot = slot;
+        }
+
+        @Override
+        public View doInBackground(View... views) {
+            log("load natural %s", (mSlot == NEXT ? "next" : "previous"));
+            final PhotoTable table = PhotoTable.this;
+            final Bitmap current = getBitmap(views[0]);
+            Bitmap decodedPhoto;
+            if (mSlot == NEXT) {
+                decodedPhoto = table.mPhotoSource.naturalNext(current,
+                    mOptions, table.mLongSide, table.mShortSide);
+            } else {
+                decodedPhoto = table.mPhotoSource.naturalPrevious(current,
+                    mOptions, table.mLongSide, table.mShortSide);
+            }
+            return applyFrame(PhotoTable.this, mOptions, decodedPhoto);
+        }
+
+        @Override
+        public void onPostExecute(View photo) {
+            if (photo != null) {
+                log("natural %s being rendered", (mSlot == NEXT ? "next" : "previous"));
+                PhotoTable.this.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
+                    LayoutParams.WRAP_CONTENT));
+                float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue();
+                float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue();
+                photo.setX(mSlot == PREV ? -2 * width : mWidth + 2 * width);
+                photo.setY((mHeight - height) / 2);
+                photo.addOnLayoutChangeListener(new OnLayoutChangeListener() {
+                    @Override
+                    public void onLayoutChange(View v, int left, int top, int right, int bottom,
+                            int oldLeft, int oldTop, int oldRight, int oldBottom) {
+                        PhotoTable.this.placeOnDeck(v, mSlot);
+                        v.removeOnLayoutChangeListener(this);
+                    }
+                });
+            } else {
+                log("natural, %s was null!", (mSlot == NEXT ? "next" : "previous"));
+            }
+        }
+    };
+
     private class PhotoLaunchTask extends AsyncTask<Void, Void, View> {
         private final BitmapFactory.Options mOptions;
 
@@ -428,35 +503,9 @@
         public View doInBackground(Void... unused) {
             log("load a new photo");
             final PhotoTable table = PhotoTable.this;
-
-            LayoutInflater inflater = (LayoutInflater) table.getContext()
-                   .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-            View photo = inflater.inflate(R.layout.photo, null);
-            ImageView image = (ImageView) photo;
-            Drawable[] layers = new Drawable[2];
-            Bitmap decodedPhoto = table.mPhotoSource.next(mOptions,
-                    table.mLongSide, table.mShortSide);
-            int photoWidth = mOptions.outWidth;
-            int photoHeight = mOptions.outHeight;
-            if (decodedPhoto == null || mOptions.outWidth <= 0 || mOptions.outHeight <= 0) {
-                photo = null;
-            } else {
-                decodedPhoto.setHasMipMap(true);
-                layers[0] = new BitmapDrawable(table.mResources, decodedPhoto);
-                layers[1] = table.mResources.getDrawable(R.drawable.frame);
-                LayerDrawable layerList = new LayerDrawable(layers);
-                layerList.setLayerInset(0, table.mInset, table.mInset,
-                                        table.mInset, table.mInset);
-                image.setImageDrawable(layerList);
-
-                photo.setTag(R.id.photo_width, new Integer(photoWidth));
-                photo.setTag(R.id.photo_height, new Integer(photoHeight));
-
-                photo.setOnTouchListener(new PhotoTouchListener(table.getContext(),
-                                                                table));
-            }
-
-            return photo;
+            return applyFrame(PhotoTable.this, mOptions,
+                 table.mPhotoSource.next(mOptions,
+                      table.mLongSide, table.mShortSide));
         }
 
         @Override
@@ -465,12 +514,15 @@
                 final PhotoTable table = PhotoTable.this;
 
                 table.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
-                                                       LayoutParams.WRAP_CONTENT));
+                    LayoutParams.WRAP_CONTENT));
                 if (table.hasSelection()) {
-                    table.moveToTopOfPile(table.getSelected());
+                    for (int slot = 0; slot < mOnDeck.length; slot++) {
+                        if (mOnDeck[slot] != null) {
+                            table.moveToTopOfPile(mOnDeck[slot]);
+                        }
+                    }
+                    table.moveToTopOfPile(table.getSelection());
                 }
-                int width = ((Integer) photo.getTag(R.id.photo_width)).intValue();
-                int height = ((Integer) photo.getTag(R.id.photo_height)).intValue();
 
                 log("drop it");
                 table.throwOnTable(photo);
@@ -489,11 +541,12 @@
         }
     };
 
+    /** Bring a new photo onto the table. */
     public void launch() {
         log("launching");
-        setSystemUiVisibility(View.STATUS_BAR_HIDDEN);
+        setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE);
         if (hasSelection() &&
-                (System.currentTimeMillis() - mSelectedTime) > MAX_SELECTION_TIME) {
+                (System.currentTimeMillis() - mSelectionTime) > MAX_SELECTION_TIME) {
             clearSelection();
         } else {
             log("inflate it");
@@ -504,12 +557,11 @@
             }
         }
     }
+
+    /** Dispose of the photo gracefully, in case we can see some of it. */
     public void fadeAway(final View photo, final boolean replace) {
         // fade out of view
         mOnTable.remove(photo);
-        if (photo == getFocused()) {
-            clearFocus();
-        }
         photo.animate().cancel();
         photo.animate()
                 .withLayer()
@@ -518,7 +570,9 @@
                 .withEndAction(new Runnable() {
                         @Override
                         public void run() {
-                            removeView(photo);
+                            if (photo == getFocus()) {
+                                clearFocus();
+                            }
                             recycle(photo);
                             if (replace) {
                                 scheduleNext(mNowDropDelay);
@@ -527,6 +581,7 @@
                     });
     }
 
+    /** Visually on top, and also freshest, for the purposes of timeouts. */
     public void moveToTopOfPile(View photo) {
         // make this photo the last to be removed.
         bringChildToFront(photo);
@@ -535,11 +590,53 @@
         mOnTable.offer(photo);
     }
 
+    /** On deck is to the left or right of the selected photo. */
+    private void placeOnDeck(final View photo, final int slot ) {
+        if (slot < mOnDeck.length) {
+            if (mOnDeck[slot] != null && mOnDeck[slot] != photo) {
+                fadeAway(mOnDeck[slot], false);
+            }
+            mOnDeck[slot] = photo;
+            float photoWidth = photo.getWidth();
+            float photoHeight = photo.getHeight();
+            float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth);
+
+            float x = (getWidth() - photoWidth) / 2f;
+            float y = (getHeight() - photoHeight) / 2f;
+
+            View selected = getSelection();
+            float selectedWidth = selected.getWidth();
+            float selectedHeight = selected.getHeight();
+            float selectedScale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth);
+
+            float offset = (((float) mWidth + scale * (photoWidth - 2f * mInset)) / 2f);
+            x += (slot == NEXT? 1f : -1f) * offset;
+
+            photo.animate()
+                .rotation(0f)
+                .rotationY(0f)
+                .scaleX(scale)
+                .scaleY(scale)
+                .x(x)
+                .y(y)
+                .setDuration(1000)
+                .setInterpolator(new DecelerateInterpolator(2f));
+        }
+    }
+
+    /** Move in response to touch. */
+    public void move(final View photo, float x, float y, float a) {
+        photo.animate().cancel();
+        photo.setAlpha(1f);
+        photo.setX((int) x);
+        photo.setY((int) y);
+        photo.setRotation((int) a);
+    }
+
+    /** Wind up off screen, so we can animate in. */
     private void throwOnTable(final View photo) {
         mOnTable.offer(photo);
         log("start offscreen");
-        int width = ((Integer) photo.getTag(R.id.photo_width));
-        int height = ((Integer) photo.getTag(R.id.photo_height));
         photo.setRotation(mThrowRotation);
         photo.setX(-mLongSide);
         photo.setY(-mLongSide);
@@ -560,6 +657,7 @@
         }
     }
 
+    /** Fling with no touch hints, then land off screen. */
     public void fling(final View photo) {
         final float[] o = { mWidth + mLongSide / 2f,
                             mHeight + mLongSide / 2f };
@@ -580,8 +678,9 @@
         fling(photo, delta[0], delta[1], duration, true);
     }
 
+    /** Continue dynamically after a fling gesture, possibly off the screen. */
     public void fling(final View photo, float dx, float dy, int duration, boolean spin) {
-        if (photo == getFocused()) {
+        if (photo == getFocus()) {
             if (moveFocus(photo, 0f) == null) {
                 moveFocus(photo, 180f);
             }
@@ -618,10 +717,12 @@
                 hit.right < 0f || hit.left > getWidth());
     }
 
+    /** Animate to a random place and orientation, down on the table (visually small). */
     public void dropOnTable(final View photo) {
         dropOnTable(photo, mDropInterpolator);
     }
 
+    /** Animate to a random place and orientation, down on the table (visually small). */
     public void dropOnTable(final View photo, final Interpolator interpolator) {
         float angle = randfrange(-mImageRotationLimit, mImageRotationLimit);
         PointF p = randMultiDrop(sRNG.nextInt(),
@@ -630,16 +731,14 @@
         float x = p.x;
         float y = p.y;
 
-        log("drop it at " + x + ", " + y);
+        log("drop it at %f, %f", x, y);
 
         float x0 = photo.getX();
         float y0 = photo.getY();
-        float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue();
-        float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue();
 
         x -= mLongSide / 2f;
         y -= mShortSide / 2f;
-        log("fixed offset is " + x + ", " + y);
+        log("fixed offset is %f, %f ", x, y);
 
         float dx = x - x0;
         float dy = y - y0;
@@ -668,12 +767,14 @@
         return result;
     }
 
+    /** Animate the selected photo to the foregound: zooming in to bring it foreward. */
     private void pickUp(final View photo) {
         float photoWidth = photo.getWidth();
         float photoHeight = photo.getHeight();
 
         float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth);
 
+        log("scale is %f", scale);
         log("target it");
         float x = (getWidth() - photoWidth) / 2f;
         float y = (getHeight() - photoHeight) / 2f;
@@ -690,9 +791,10 @@
         photo.setRotation(wrapAngle(photo.getRotation()));
 
         log("animate it");
-        // toss onto table
+        // lift up to the glass for a good look
         photo.animate()
                 .rotation(0f)
+                .rotationY(0f)
                 .scaleX(scale)
                 .scaleY(scale)
                 .x(x)
@@ -702,16 +804,35 @@
                 .withEndAction(new Runnable() {
                         @Override
                             public void run() {
-                            log("endtimes: " + photo.getX());
+                            log("endtimes: %f", photo.getX());
                         }
                     });
     }
 
-    private void recycle(View photo) {
+    private Bitmap getBitmap(View photo) {
+        if (photo == null) {
+            return null;
+        }
         ImageView image = (ImageView) photo;
         LayerDrawable layers = (LayerDrawable) image.getDrawable();
+        if (layers == null) {
+            return null;
+        }
         BitmapDrawable bitmap = (BitmapDrawable) layers.getDrawable(0);
-        bitmap.getBitmap().recycle();
+        if (bitmap == null) {
+            return null;
+        }
+        return bitmap.getBitmap();
+    }
+
+    private void recycle(View photo) {
+        if (photo != null) {
+            removeView(photo);
+            Bitmap bitmap = getBitmap(photo);
+            if (bitmap != null) {
+                bitmap.recycle();
+            }
+        }
     }
 
     public void setHighlight(View photo, boolean highlighted) {
@@ -724,6 +845,7 @@
         }
     }
 
+    /** Schedule the first launch.  Idempotent. */
     public void start() {
         if (!mStarted) {
             log("kick it");
@@ -747,9 +869,11 @@
         postDelayed(mLauncher, delay);
     }
 
-    private static void log(String message) {
+    private static void log(String message, Object... args) {
         if (DEBUG) {
-            Log.i(TAG, message);
+            Formatter formatter = new Formatter();
+            formatter.format(message, args);
+            Log.i(TAG, formatter.toString());
         }
     }
 }
diff --git a/src/com/android/dreams/phototable/PhotoTableDream.java b/src/com/android/dreams/phototable/PhotoTableDream.java
index 37ea019..dd23be2 100644
--- a/src/com/android/dreams/phototable/PhotoTableDream.java
+++ b/src/com/android/dreams/phototable/PhotoTableDream.java
@@ -15,20 +15,14 @@
  */
 package com.android.dreams.phototable;
 
-import android.content.Context;
-import android.content.SharedPreferences;
 import android.content.res.Resources;
 import android.service.dreams.DreamService;
 
-import java.util.Set;
-
 /**
  * Example interactive screen saver: flick photos onto a table.
  */
 public class PhotoTableDream extends DreamService {
     public static final String TAG = "PhotoTableDream";
-    private PhotoTable mTable;
-
     @Override
     public void onDreamingStarted() {
         super.onDreamingStarted();
diff --git a/src/com/android/dreams/phototable/PhotoTableDreamSettings.java b/src/com/android/dreams/phototable/PhotoTableDreamSettings.java
index a42d4a6..7ae8df3 100644
--- a/src/com/android/dreams/phototable/PhotoTableDreamSettings.java
+++ b/src/com/android/dreams/phototable/PhotoTableDreamSettings.java
@@ -15,21 +15,13 @@
  */
 package com.android.dreams.phototable;
 
-import android.content.SharedPreferences;
-import android.app.ListActivity;
-import android.os.AsyncTask;
 import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.widget.ListAdapter;
-
-import java.util.LinkedList;
 
 /**
  * Settings panel for photo flipping dream.
  */
 public class PhotoTableDreamSettings extends FlipperDreamSettings {
+    @SuppressWarnings("unused")
     private static final String TAG = "PhotoTableDreamSettings";
     public static final String PREFS_NAME = PhotoTableDream.TAG;
 
diff --git a/src/com/android/dreams/phototable/PhotoTouchListener.java b/src/com/android/dreams/phototable/PhotoTouchListener.java
index 190dc3d..8bcec6b 100644
--- a/src/com/android/dreams/phototable/PhotoTouchListener.java
+++ b/src/com/android/dreams/phototable/PhotoTouchListener.java
@@ -34,7 +34,6 @@
     private final int mTapTimeout;
     private final PhotoTable mTable;
     private final float mBeta;
-    private final float mTableRatio;
     private final boolean mEnableFling;
     private final boolean mManualImageRotation;
     private long mLastEventTime;
@@ -52,16 +51,14 @@
     private int mA = INVALID_POINTER;
     private int mB = INVALID_POINTER;
     private float[] pts = new float[MAX_POINTER_COUNT];
-    private float[] tmp = new float[MAX_POINTER_COUNT];
 
     public PhotoTouchListener(Context context, PhotoTable table) {
         mTable = table;
         final ViewConfiguration configuration = ViewConfiguration.get(context);
         mTouchSlop = configuration.getScaledTouchSlop();
-        mTapTimeout = configuration.getTapTimeout();
+        mTapTimeout = ViewConfiguration.getTapTimeout();
         final Resources resources = context.getResources();
         mBeta = resources.getInteger(R.integer.table_damping) / 1000000f;
-        mTableRatio = resources.getInteger(R.integer.table_ratio) / 1000000f;
         mEnableFling = resources.getBoolean(R.bool.enable_fling);
         mManualImageRotation = resources.getBoolean(R.bool.enable_manual_image_rotation);
     }
@@ -184,16 +181,16 @@
                         mLastTouchY = y;
                     }
 
-                    if (mTable.getSelected() != target) {
-                        target.animate().cancel();
-
-                        target.setX((int) (mInitialTargetX + x - mInitialTouchX));
-                        target.setY((int) (mInitialTargetY + y - mInitialTouchY));
+                    if (!mTable.hasSelection()) {
+                        float rotation = target.getRotation();
                         if (mManualImageRotation && mB != INVALID_POINTER) {
                             float a = getAngle(target, ev);
-                            target.setRotation(
-                                    (int) (mInitialTargetA + a - mInitialTouchA));
+                            rotation = mInitialTargetA + a - mInitialTouchA;
                         }
+                        mTable.move(target,
+                                    mInitialTargetX + x - mInitialTouchX,
+                                    mInitialTargetY + y - mInitialTouchY,
+                                    rotation);
                     }
                 }
             }
@@ -210,13 +207,19 @@
                     }
                     double distance = Math.hypot(x0 - mInitialTouchX,
                                                  y0 - mInitialTouchY);
-                    if (mTable.getSelected() == target) {
-                        mTable.dropOnTable(target);
-                        mTable.clearSelection();
+                    if (mTable.hasSelection()) {
+                        if (distance < mTouchSlop) {
+                          mTable.clearSelection();
+                        } else {
+                          if ((x0 - mInitialTouchX) > 0f) {
+                            mTable.selectPrevious();
+                          } else {
+                            mTable.selectNext();
+                          }
+                        }
                     } else if ((ev.getEventTime() - mInitialTouchTime) < mTapTimeout &&
                                distance < mTouchSlop) {
                         // tap
-                        target.animate().cancel();
                         mTable.setSelection(target);
                     } else {
                         onFling(target, mDX, mDY);
diff --git a/src/com/android/dreams/phototable/PicasaSource.java b/src/com/android/dreams/phototable/PicasaSource.java
index ef4a7c4..eb8fd1f 100644
--- a/src/com/android/dreams/phototable/PicasaSource.java
+++ b/src/com/android/dreams/phototable/PicasaSource.java
@@ -26,7 +26,6 @@
 
 import java.io.FileNotFoundException;
 import java.io.InputStream;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -36,7 +35,7 @@
 /**
  * Loads images from Picasa.
  */
-public class PicasaSource extends PhotoSource {
+public class PicasaSource extends CursorPhotoSource {
     private static final String TAG = "PhotoTable.PicasaSource";
 
     private static final String PICASA_AUTHORITY =
@@ -61,7 +60,6 @@
     private static final String PICASA_TYPE_KEY = "type";
     private static final String PICASA_TYPE_FULL_VALUE = "full";
     private static final String PICASA_TYPE_SCREEN_VALUE = "screennail";
-    private static final String PICASA_TYPE_THUMB_VALUE = "thumbnail";
     private static final String PICASA_TYPE_IMAGE_VALUE = "image";
     private static final String PICASA_POSTS_TYPE = "Buzz";
     private static final String PICASA_UPLOAD_TYPE = "InstantUpload";
@@ -90,6 +88,7 @@
         mConnectivityManager =
                 (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
         mRecycleBin = new LinkedList<ImageData>();
+
         fillQueue();
         mDisplayLongSide = getDisplayLongSide();
     }
@@ -103,6 +102,65 @@
     }
 
     @Override
+    protected void openCursor(ImageData data) {
+        log(TAG, "opening single album");
+
+        String[] projection = {PICASA_ID, PICASA_URL, PICASA_ROTATION, PICASA_ALBUM_ID};
+        String selection = PICASA_ALBUM_ID + " = '" + data.albumId + "'";
+
+        Uri.Builder picasaUriBuilder = new Uri.Builder()
+                .scheme("content")
+                .authority(PICASA_AUTHORITY)
+                .appendPath(PICASA_PHOTO_PATH);
+        data.cursor = mResolver.query(picasaUriBuilder.build(),
+                projection, selection, null, null);
+    }
+
+    @Override
+    protected void findPosition(ImageData data) {
+        if (data.position == UNINITIALIZED) {
+            if (data.cursor == null) {
+                openCursor(data);
+            }
+            if (data.cursor != null) {
+                int idIndex = data.cursor.getColumnIndex(PICASA_ID);
+                data.cursor.moveToPosition(-1);
+                while (data.position == -1 && data.cursor.moveToNext()) {
+                    String id = data.cursor.getString(idIndex);
+                    if (id != null && id.equals(data.id)) {
+                        data.position = data.cursor.getPosition();
+                    }
+                }
+                if (data.position == -1) {
+                    // oops!  The image isn't in this album. How did we get here?
+                    data.position = INVALID;
+                }
+            }
+        }
+    }
+
+    @Override
+    protected ImageData unpackImageData(Cursor cursor, ImageData data) {
+        if (data == null) {
+            data = new ImageData();
+        }
+        int idIndex = cursor.getColumnIndex(PICASA_ID);
+        int urlIndex = cursor.getColumnIndex(PICASA_URL);
+        int bucketIndex = cursor.getColumnIndex(PICASA_ALBUM_ID);
+
+        data.id = cursor.getString(idIndex);
+        if (bucketIndex >= 0) {
+            data.albumId = cursor.getString(bucketIndex);
+        }
+        if (urlIndex >= 0) {
+            data.url = cursor.getString(urlIndex);
+        }
+        data.position = UNINITIALIZED;
+        data.cursor = null;
+        return data;
+    }
+
+    @Override
     protected Collection<ImageData> findImages(int howMany) {
         log(TAG, "finding images");
         LinkedList<ImageData> foundImages = new LinkedList<ImageData>();
@@ -117,7 +175,6 @@
         }
 
         String[] projection = {PICASA_ID, PICASA_URL, PICASA_ROTATION, PICASA_ALBUM_ID};
-        boolean usePosts = false;
         LinkedList<String> albumIds = new LinkedList<String>();
         for (String id : getFoundAlbums()) {
             if (mSettings.isAlbumEnabled(id)) {
@@ -170,22 +227,13 @@
             cursor.moveToPosition(mLastPosition);
 
             int idIndex = cursor.getColumnIndex(PICASA_ID);
-            int urlIndex = cursor.getColumnIndex(PICASA_URL);
-            int orientationIndex = cursor.getColumnIndex(PICASA_ROTATION);
-            int bucketIndex = cursor.getColumnIndex(PICASA_ALBUM_ID);
 
             if (idIndex < 0) {
                 log(TAG, "can't find the ID column!");
             } else {
                 while (cursor.moveToNext()) {
                     if (idIndex >= 0) {
-                        ImageData data = new ImageData();
-                        data.id = cursor.getString(idIndex);
-
-                        if (urlIndex >= 0) {
-                            data.url = cursor.getString(urlIndex);
-                        }
-
+                        ImageData data = unpackImageData(cursor, null);
                         foundImages.offer(data);
                     }
                     mLastPosition = cursor.getPosition();
@@ -255,7 +303,6 @@
             cursor.moveToPosition(-1);
 
             int idIndex = cursor.getColumnIndex(PICASA_ID);
-            int typeIndex = cursor.getColumnIndex(PICASA_ALBUM_TYPE);
 
             if (idIndex < 0) {
                 log(TAG, "can't find the ID column!");
@@ -398,9 +445,6 @@
         } catch (FileNotFoundException fnf) {
             log(TAG, "file not found: " + fnf);
             is = null;
-        } catch (IOException ioe) {
-            log(TAG, "i/o exception: " + ioe);
-            is = null;
         }
 
         if (is != null) {
diff --git a/src/com/android/dreams/phototable/SectionedAlbumDataAdapter.java b/src/com/android/dreams/phototable/SectionedAlbumDataAdapter.java
index 6c5a88a..42f2eb0 100644
--- a/src/com/android/dreams/phototable/SectionedAlbumDataAdapter.java
+++ b/src/com/android/dreams/phototable/SectionedAlbumDataAdapter.java
@@ -21,10 +21,9 @@
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
-import android.view.View.OnClickListener;
 import android.view.ViewGroup;
-import android.widget.TextView;
 import android.widget.ListAdapter;
+import android.widget.TextView;
 
 import java.util.Arrays;
 import java.util.List;
@@ -39,13 +38,11 @@
     private final LayoutInflater mInflater;
     private final int mLayout;
     private final AlbumDataAdapter mAlbumData;
-    private final Context mContext;
     private int[] sections;
 
     public SectionedAlbumDataAdapter(Context context, SharedPreferences settings,
             int headerLayout, int itemLayout, List<PhotoSource.AlbumData> objects) {
         mLayout = headerLayout;
-        mContext = context;
         mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
         mAlbumData = new AlbumDataAdapter(context, settings, itemLayout, objects);
         mAlbumData.sort(new AlbumDataAdapter.AccountComparator());
diff --git a/src/com/android/dreams/phototable/SoftLandingInterpolator.java b/src/com/android/dreams/phototable/SoftLandingInterpolator.java
index bb2c1bd..6a6020d 100644
--- a/src/com/android/dreams/phototable/SoftLandingInterpolator.java
+++ b/src/com/android/dreams/phototable/SoftLandingInterpolator.java
@@ -16,10 +16,9 @@
 
 package com.android.dreams.phototable;
 
-import android.view.animation.Interpolator;
 import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
 import android.view.animation.LinearInterpolator;
-import android.util.Log;
 
 /**
  * An interpolator where the rate of change starts out quickly and
@@ -31,7 +30,6 @@
     private final DecelerateInterpolator slide;
     private final float mI;
     private final float mO;
-    private final float lowerRange;
     private final float upperRange;
     private final float bottom;
     private final float top;
@@ -44,7 +42,6 @@
         final float epsilon = Math.min(mI / 2f, (1f - mI) / 2f);
         bottom = mI - epsilon;
         top = mI + epsilon;
-        lowerRange = top;
         upperRange = 1f - bottom;
     }
 
diff --git a/src/com/android/dreams/phototable/StockSource.java b/src/com/android/dreams/phototable/StockSource.java
index 3d44309..d7b3500 100644
--- a/src/com/android/dreams/phototable/StockSource.java
+++ b/src/com/android/dreams/phototable/StockSource.java
@@ -17,45 +17,55 @@
 
 import android.content.Context;
 import android.content.SharedPreferences;
-import android.util.Log;
 
 import java.io.InputStream;
-import java.util.Collection;
 import java.util.ArrayList;
+import java.util.Collection;
 
 /**
  * Picks a random image from the local store.
  */
-public class 
-
-StockSource extends PhotoSource {
+public class StockSource extends PhotoSource {
     public static final String ALBUM_ID = "com.android.dreams.phototable.StockSource";
     private static final String TAG = "PhotoTable.StockSource";
     private static final int[] PHOTOS = { R.drawable.blank_photo };
 
+    private final ArrayList<ImageData> mImageCache;
+    private final ArrayList<AlbumData> mAlbumCache;
+
     private final ArrayList<ImageData> mImageList;
     private final ArrayList<AlbumData> mAlbumList;
 
     private final String mStockPhotoName;
-    private int mNextPosition;
 
     public StockSource(Context context, SharedPreferences settings) {
         super(context, settings, null);
         mSourceName = TAG;
         mStockPhotoName = mResources.getString(R.string.stock_photo_album_name, "Default Photos");
+        mImageCache = new ArrayList<ImageData>(PHOTOS.length);
+        mAlbumCache = new ArrayList<AlbumData>(1);
         mImageList = new ArrayList<ImageData>(PHOTOS.length);
         mAlbumList = new ArrayList<AlbumData>(1);
+
+        AlbumData albumData = new AlbumData();
+        albumData.id = ALBUM_ID;
+        albumData.account = mStockPhotoName;
+        albumData.title = mStockPhotoName;
+        mAlbumCache.add(albumData);
+
+        for (int i = 0; i < PHOTOS.length; i++) {
+            ImageData imageData = new ImageData();
+            imageData.id = Integer.toString(i);
+            mImageCache.add(imageData);
+        }
+
         fillQueue();
     }
 
     @Override
     public Collection<AlbumData> findAlbums() {
         if (mAlbumList.isEmpty()) {
-            AlbumData data = new AlbumData();
-            data.id = ALBUM_ID;
-            data.account = mStockPhotoName;
-            data.title = mStockPhotoName;
-            mAlbumList.add(data);
+            mAlbumList.addAll(mAlbumCache);
         }
         log(TAG, "returning a list of albums: " + mAlbumList.size());
         return mAlbumList;
@@ -64,11 +74,7 @@
     @Override
     protected Collection<ImageData> findImages(int howMany) {
         if (mImageList.isEmpty()) {
-            for (int i = 0; i < PHOTOS.length; i++) {
-                ImageData data = new ImageData();
-                data.id = Integer.toString(PHOTOS[i]);
-                mImageList.add(data);
-            }
+            mImageList.addAll(mImageCache);
         }
         return mImageList;
     }
@@ -78,7 +84,8 @@
         InputStream is = null;
         try {
             log(TAG, "opening:" + data.id);
-            is = mResources.openRawResource(Integer.valueOf(data.id));
+            int idx = Integer.valueOf(data.id);
+            is = mResources.openRawResource(PHOTOS[idx]);
         } catch (Exception ex) {
             log(TAG, ex.toString());
             is = null;
@@ -86,5 +93,17 @@
 
         return is;
     }
+
+    public ImageData naturalNext(ImageData current) {
+        int idx = Integer.valueOf(current.id);
+        idx = (idx + 1) % PHOTOS.length;
+        return mImageCache.get(idx);
+    }
+
+    public ImageData naturalPrevious(ImageData current) {
+        int idx = Integer.valueOf(current.id);
+        idx = (PHOTOS.length + idx - 1) % PHOTOS.length;
+        return mImageCache.get(idx);
+    }
 }