package org.music.player; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.widget.Scroller; /** * Displays a flingable/draggable View of cover art/song info images * generated by CoverBitmap. */ public final class CoverView extends View implements Handler.Callback { /** * The system-provided snap velocity, used as a threshold for detecting * flings. */ private static int sSnapVelocity = -1; /** * The screen density, from {@link DisplayMetrics#density}. */ private static double sDensity = -1; /** * The Handler with which to do background work. Will be null until * setupHandler is called. */ private Handler mHandler; /** * A handler running on the UI thread, for UI operations. */ private final Handler mUiHandler = new Handler(this); /** * How to render cover art and metadata. One of * CoverBitmap.STYLE_* */ private int mCoverStyle; /** * Interface to respond to CoverView motion actions. */ public interface Callback { /** * Called after the view has scrolled to the previous or next cover. * * @param delta -1 for the previous cover, 1 for the next. */ public void shiftCurrentSong(int delta); /** * Called when the user has swiped up on the view. */ public void upSwipe(); /** * Called when the user has swiped down on the view. */ public void downSwipe(); } /** * The instance of the callback. */ private Callback mCallback; /** * The current set of songs: 0 = previous, 1 = current, and 2 = next. */ private Song[] mSongs = new Song[3]; /** * The covers for the current songs: 0 = previous, 1 = current, and 2 = next. */ private Bitmap[] mBitmaps = new Bitmap[3]; /** * The bitmaps to be drawn. Usually the same as mBitmaps, unless scrolling. */ private Bitmap[] mActiveBitmaps = mBitmaps; /** * Cover art to use when a song has no cover art in no info display styles. */ private Bitmap mDefaultCover; /** * Computes scroll animations. */ private final Scroller mScroller; /** * Computes scroll velocity to detect flings. */ private VelocityTracker mVelocityTracker; /** * The x coordinate of the last touch down or move event. */ private float mLastMotionX; /** * The y coordinate of the last touch down or move event. */ private float mLastMotionY; /** * The x coordinate of the last touch down event. */ private float mStartX; /** * The y coordinate of the last touch down event. */ private float mStartY; /** * Ignore the next pointer up event, for long presses. */ private boolean mIgnoreNextUp; /** * If true, querySongs was called before the view initialized and should * be called when initialization finishes. */ private boolean mPendingQuery; /** * The current x scroll position of the view. * * Scrolling code from {@link View} is not used for this class since many of * its features are not required. */ private int mScrollX; /** * True if a scroll is in progress (i.e. mScrollX != getWidth()), false * otherwise. */ private boolean mScrolling; /** * Constructor intended to be called by inflating from XML. */ public CoverView(Context context, AttributeSet attributes) { super(context, attributes); mScroller = new Scroller(context); if (sSnapVelocity == -1) { sSnapVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); sDensity = context.getResources().getDisplayMetrics().density; } } /** * Setup the Handler and callback. This must be called before * the CoverView is used. * * @param looper A looper created on a worker thread. * @param callback The callback for nextSong/previousSong * @param style One of CoverBitmap.STYLE_* */ public void setup(Looper looper, Callback callback, int style) { mHandler = new Handler(looper, this); mCallback = callback; mCoverStyle = style; } /** * Reset the scroll position to its default state. */ private void resetScroll() { if (!mScroller.isFinished()) mScroller.abortAnimation(); mScrollX = getWidth(); invalidate(); } @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { if (mPendingQuery && width != 0 && height != 0) { mPendingQuery = false; querySongs(PlaybackService.get(getContext())); } } /** * Paint the cover art views to the canvas. */ @Override protected void onDraw(Canvas canvas) { int width = getWidth(); int height = getHeight(); int x = 0; int scrollX = mScrollX; canvas.drawColor(Color.BLACK); for (Bitmap bitmap : mActiveBitmaps) { if (bitmap != null && scrollX + width > x && scrollX < x + width) { int xOffset = (width - bitmap.getWidth()) / 2; int yOffset = (height - bitmap.getHeight()) / 2; canvas.drawBitmap(bitmap, x + xOffset - scrollX, yOffset, null); } x += width; } } /** * Scrolls the view when dragged. Animates a fling to one of the three covers * when finished. The cover flung to will be either the nearest cover, or if * the fling is fast enough, the cover in the direction of the fling. * * Also performs a click on the view when it is tapped without dragging. */ @Override public boolean onTouchEvent(MotionEvent ev) { if (mVelocityTracker == null) mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(ev); float x = ev.getX(); float y = ev.getY(); int scrollX = mScrollX; int width = getWidth(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: if (!mScroller.isFinished()) { mScroller.abortAnimation(); mActiveBitmaps = mBitmaps; } mStartX = x; mStartY = y; mLastMotionX = x; mLastMotionY = y; mScrolling = true; mUiHandler.sendEmptyMessageDelayed(MSG_LONG_CLICK, ViewConfiguration.getLongPressTimeout()); break; case MotionEvent.ACTION_MOVE: { float deltaX = mLastMotionX - x; float deltaY = mLastMotionY - y; if (Math.abs(deltaX) > Math.abs(deltaY)) { if (deltaX < 0) { int availableToScroll = scrollX - (mSongs[0] == null ? width : 0); if (availableToScroll > 0) { mScrollX += Math.max(-availableToScroll, (int)deltaX); invalidate(); } } else if (deltaX > 0) { int availableToScroll = width * 2 - scrollX; if (availableToScroll > 0) { mScrollX += Math.min(availableToScroll, (int)deltaX); invalidate(); } } } mLastMotionX = x; mLastMotionY = y; break; } case MotionEvent.ACTION_UP: { mUiHandler.removeMessages(MSG_LONG_CLICK); VelocityTracker velocityTracker = mVelocityTracker; velocityTracker.computeCurrentVelocity(250); int velocityX = (int) velocityTracker.getXVelocity(); int velocityY = (int) velocityTracker.getYVelocity(); int mvx = Math.abs(velocityX); int mvy = Math.abs(velocityY); // If -1 or 1, play the previous or next song, respectively and scroll // to that song's cover. If 0, just scroll back to current song's cover. int whichCover = 0; int min = mSongs[0] == null ? 0 : -1; int max = 1; if (Math.abs(mStartX - x) + Math.abs(mStartY - y) < 10) { // A long press was performed and thus the normal action should // not be executed. if (mIgnoreNextUp) mIgnoreNextUp = false; else performClick(); } else if (mvx > sSnapVelocity || mvy > sSnapVelocity) { if (mvy > mvx) { if (velocityY > 0) mCallback.downSwipe(); else mCallback.upSwipe(); } else { if (velocityX > 0) whichCover = min; else whichCover = max; } } else { int nearestCover = (scrollX + width / 2) / width - 1; whichCover = Math.max(min, Math.min(nearestCover, max)); } if (whichCover != 0) { scrollX = scrollX - width * whichCover; Bitmap[] bitmaps = mBitmaps; // Save the two covers being scrolled between, so that if one // of them changes from switching songs (which can happen when // shuffling), the new cover doesn't pop in during the scroll. // mActiveBitmaps is reset when the scroll is finished. if (whichCover == 1) { mActiveBitmaps = new Bitmap[] { bitmaps[1], bitmaps[2], null }; } else { mActiveBitmaps = new Bitmap[] { null, bitmaps[0], bitmaps[1] }; } mCallback.shiftCurrentSong(whichCover); mScrollX = scrollX; } int delta = width - scrollX; mScroller.startScroll(scrollX, 0, delta, 0, (int)(Math.abs(delta) * 2 / sDensity)); mUiHandler.sendEmptyMessage(MSG_SCROLL); if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } break; } } return true; } /** * Generates a bitmap for the given song. * * @param i The position of the song in mSongs. */ private void generateBitmap(int i) { Song song = mSongs[i]; int style = mCoverStyle; Context context = getContext(); Bitmap cover = song == null ? null : song.getCover(context); if (cover == null && style == CoverBitmap.STYLE_NO_INFO) { Bitmap def = mDefaultCover; if (def == null) { mDefaultCover = def = CoverBitmap.generateDefaultCover(getWidth(), getHeight()); } mBitmaps[i] = def; } else { mBitmaps[i] = CoverBitmap.createBitmap(context, style, cover, song, getWidth(), getHeight()); } postInvalidate(); } /** * Set the Song at position <code>i</code> to <code>song</code>, generating * the bitmap for it in the background if needed. */ public void setSong(int i, Song song) { if (song == mSongs[i]) return; mSongs[i] = song; mBitmaps[i] = null; if (song != null) { mHandler.sendMessage(mHandler.obtainMessage(MSG_GENERATE_BITMAP, i, 0)); } } /** * Query all songs. Must be called on the UI thread. * * @param service Service to query from. */ public void querySongs(PlaybackService service) { if (getWidth() == 0 || getHeight() == 0) { mPendingQuery = true; return; } mHandler.removeMessages(MSG_GENERATE_BITMAP); Song[] songs = mSongs; Bitmap[] bitmaps = mBitmaps; Song[] newSongs = { service.getSong(-1), service.getSong(0), service.getSong(1) }; Bitmap[] newBitmaps = new Bitmap[3]; mSongs = newSongs; mBitmaps = newBitmaps; if (!mScrolling) mActiveBitmaps = newBitmaps; for (int i = 0; i != 3; ++i) { if (newSongs[i] == null) continue; for (int j = 0; j != 3; ++j) { if (newSongs[i] == songs[j]) { newBitmaps[i] = bitmaps[j]; break; } } if (newBitmaps[i] == null) { mHandler.sendMessage(mHandler.obtainMessage(MSG_GENERATE_BITMAP, i, 0)); } } resetScroll(); } /** * Call {@link CoverView#generateBitmap(int)} for the song at the given index. * * arg1 should be the index of the song. */ private static final int MSG_GENERATE_BITMAP = 0; /** * Perform a long click. * * @see View#performLongClick() */ private static final int MSG_LONG_CLICK = 2; /** * Update position for fling scroll animation and, when it is finished, * notify PlaybackService that the user has requested a track change and * update the cover art views. Will resend message until scrolling is * finished. */ private static final int MSG_SCROLL = 3; @Override public boolean handleMessage(Message message) { switch (message.what) { case MSG_GENERATE_BITMAP: generateBitmap(message.arg1); break; case MSG_LONG_CLICK: if (Math.abs(mStartX - mLastMotionX) + Math.abs(mStartY - mLastMotionY) < 10) { mIgnoreNextUp = true; performLongClick(); } break; case MSG_SCROLL: if (mScroller.computeScrollOffset()) { mScrollX = mScroller.getCurrX(); invalidate(); mUiHandler.sendEmptyMessage(MSG_SCROLL); } else { mScrolling = false; mActiveBitmaps = mBitmaps; } break; default: return false; } return true; } @Override protected void onMeasure(int widthSpec, int heightSpec) { // This implementation only tries to handle two cases: use in the // FullPlaybackActivity, where we want to fill the whole screen, // and use in the MiniPlaybackActivity, where we want to be square. int width = View.MeasureSpec.getSize(widthSpec); int height = View.MeasureSpec.getSize(heightSpec); if (View.MeasureSpec.getMode(widthSpec) == View.MeasureSpec.EXACTLY && View.MeasureSpec.getMode(heightSpec) == View.MeasureSpec.EXACTLY) { // FullPlaybackActivity: fill screen setMeasuredDimension(width, height); } else { // MiniPlaybackActivity: be square int size = Math.min(width, height); setMeasuredDimension(size, size); } } }