Re-implement CoverView.
This implementation should hopefully drop less frames.
This commit is contained in:
parent
b308b97880
commit
6bbc3800c2
@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2012 Christopher Eby <kreed@kreed.org>
|
* Copyright (C) 2017 Adrian Ulrich <adrian@blinkenlights.ch>
|
||||||
* Copyright (C) 2015 Adrian Ulrich <adrian@blinkenlights.ch>
|
|
||||||
*
|
*
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
@ -26,93 +25,79 @@ package ch.blinkenlights.android.vanilla;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.Canvas;
|
import android.graphics.Canvas;
|
||||||
import android.graphics.Color;
|
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.util.DisplayMetrics;
|
import android.view.View;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.view.VelocityTracker;
|
import android.view.VelocityTracker;
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewConfiguration;
|
import android.view.ViewConfiguration;
|
||||||
|
import android.view.animation.LinearInterpolator;
|
||||||
import android.widget.Scroller;
|
import android.widget.Scroller;
|
||||||
|
|
||||||
|
import java.lang.IllegalStateException;
|
||||||
|
import java.lang.IllegalArgumentException;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a flingable/draggable View of cover art/song info images
|
* Displays a flingable/draggable View of cover art/song info images
|
||||||
* generated by CoverBitmap.
|
* generated by CoverBitmap.
|
||||||
*/
|
*/
|
||||||
public final class CoverView extends View implements Handler.Callback {
|
public final class CoverView extends View implements Handler.Callback {
|
||||||
/**
|
/**
|
||||||
* The system-provided snap velocity, used as a threshold for detecting
|
* If >= 0, perform the switch after this delay
|
||||||
* flings.
|
|
||||||
*/
|
*/
|
||||||
private static int sSnapVelocity = -1;
|
private final static int ASYNC_SWITCH = -1;
|
||||||
/**
|
/**
|
||||||
* The screen density, from {@link DisplayMetrics#density}.
|
* Maximum amount of pixels we are allowed to scroll to consider
|
||||||
|
* touch events to be normal touches.
|
||||||
|
*/
|
||||||
|
private final static double TOUCH_MAX_SCROLL_PX = 10;
|
||||||
|
/**
|
||||||
|
* The system provided display density
|
||||||
*/
|
*/
|
||||||
private static double sDensity = -1;
|
private static double sDensity = -1;
|
||||||
/**
|
/**
|
||||||
* The Handler with which to do background work. Will be null until
|
* The minimum velocity to move to the next song
|
||||||
* setupHandler is called.
|
|
||||||
*/
|
*/
|
||||||
private Handler mHandler;
|
private static double sSnapVelocity = -1;
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
void shiftCurrentSong(int delta);
|
|
||||||
/**
|
|
||||||
* Called when the user has swiped up on the view.
|
|
||||||
*/
|
|
||||||
void upSwipe();
|
|
||||||
/**
|
|
||||||
* Called when the user has swiped down on the view.
|
|
||||||
*/
|
|
||||||
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.
|
* Computes scroll velocity to detect flings.
|
||||||
*/
|
*/
|
||||||
private VelocityTracker mVelocityTracker;
|
private VelocityTracker mVelocityTracker;
|
||||||
|
/**
|
||||||
|
* The context.
|
||||||
|
*/
|
||||||
|
private final Context mContext;
|
||||||
|
/**
|
||||||
|
* The scroller instance we are using.
|
||||||
|
*/
|
||||||
|
private final CoverScroller mScroller;
|
||||||
|
/**
|
||||||
|
* Our bitmap cache helper
|
||||||
|
*/
|
||||||
|
private BitmapBucket mBitmapBucket;
|
||||||
|
/**
|
||||||
|
* Our callback to dispatch song events.
|
||||||
|
*/
|
||||||
|
private CoverView.Callback mCallback;
|
||||||
|
/**
|
||||||
|
* Indicates that we attempted to query and update our songs but couldn't as the
|
||||||
|
* view was not yet ready.
|
||||||
|
*/
|
||||||
|
private boolean mPendingQuery;
|
||||||
|
/**
|
||||||
|
* The x coordinate of the initial touch event.
|
||||||
|
*/
|
||||||
|
private float mInitialMotionX;
|
||||||
|
/**
|
||||||
|
* The y coordinate of the initial touch event.
|
||||||
|
*/
|
||||||
|
private float mInitialMotionY;
|
||||||
/**
|
/**
|
||||||
* The x coordinate of the last touch down or move event.
|
* The x coordinate of the last touch down or move event.
|
||||||
*/
|
*/
|
||||||
@ -122,82 +107,190 @@ public final class CoverView extends View implements Handler.Callback {
|
|||||||
*/
|
*/
|
||||||
private float mLastMotionY;
|
private float mLastMotionY;
|
||||||
/**
|
/**
|
||||||
* The x coordinate of the last touch down event.
|
* The message handler used by this class.
|
||||||
*/
|
*/
|
||||||
private float mStartX;
|
private Handler mHandler;
|
||||||
/**
|
/**
|
||||||
* The y coordinate of the last touch down event.
|
* Our current scroll position.
|
||||||
|
* Setting this to '0' means that we will display bitmap[0].
|
||||||
*/
|
*/
|
||||||
private float mStartY;
|
private int mScrollX = -1;
|
||||||
/**
|
/**
|
||||||
* Ignore the next pointer up event, for long presses.
|
* The style to use for the cover.
|
||||||
*/
|
*/
|
||||||
private boolean mIgnoreNextUp;
|
private int mCoverStyle = -1;
|
||||||
/**
|
/**
|
||||||
* If true, querySongs was called before the view initialized and should
|
* Our public callback interface
|
||||||
* be called when initialization finishes.
|
|
||||||
*/
|
*/
|
||||||
private boolean mPendingQuery;
|
public interface Callback {
|
||||||
/**
|
void shiftCurrentSong(int delta);
|
||||||
* The current x scroll position of the view.
|
void upSwipe();
|
||||||
*
|
void downSwipe();
|
||||||
* 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
|
* Constructs a new CoverView class, note that setup() must be called
|
||||||
* the CoverView is used.
|
* before the view becomes useable.
|
||||||
*
|
|
||||||
* @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)
|
public CoverView(Context context, AttributeSet attributes) {
|
||||||
{
|
super(context, attributes);
|
||||||
|
if (sDensity == -1) {
|
||||||
|
sDensity = context.getResources().getDisplayMetrics().density;
|
||||||
|
sSnapVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity() * 5;
|
||||||
|
}
|
||||||
|
mContext = context;
|
||||||
|
mBitmapBucket = new BitmapBucket();
|
||||||
|
mScroller = new CoverScroller(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures and sets up this view
|
||||||
|
*/
|
||||||
|
public void setup(Looper looper, Callback callback, int style) {
|
||||||
mHandler = new Handler(looper, this);
|
mHandler = new Handler(looper, this);
|
||||||
mCallback = callback;
|
mCallback = callback;
|
||||||
mCoverStyle = style;
|
mCoverStyle = style;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the scroll position to its default state.
|
* Sent if the songs timeline changed and we should check if
|
||||||
|
* mCacheBitmap is stale.
|
||||||
|
* Just calls querySongsInternal() via handler to ensure
|
||||||
|
* that we do this in a background thread.
|
||||||
*/
|
*/
|
||||||
private void resetScroll()
|
public void querySongs() {
|
||||||
{
|
mHandler.removeMessages(MSG_QUERY_SONGS);
|
||||||
if (!mScroller.isFinished())
|
mHandler.sendEmptyMessage(MSG_QUERY_SONGS);
|
||||||
mScroller.abortAnimation();
|
|
||||||
mScrollX = getWidth();
|
|
||||||
invalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called if a specific song got replaced.
|
||||||
|
* The current implementation does not take this hint into
|
||||||
|
* account as querySongsInternal() already tries to be efficient.
|
||||||
|
*/
|
||||||
|
public void replaceSong(int delta, Song song) {
|
||||||
|
querySongs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by querySongs() - this runs in a background thread.
|
||||||
|
*/
|
||||||
|
private void querySongsInternal() {
|
||||||
|
DEBUG("querySongsInternal");
|
||||||
|
|
||||||
|
if (getWidth() < 1 || getHeight() < 1) {
|
||||||
|
mPendingQuery = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mScrollX < 0) { // initialize mScrollX to show cover '1' by default.
|
||||||
|
mScrollX = getWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
mHandler.removeMessages(MSG_SET_BITMAP);
|
||||||
|
PlaybackService service = PlaybackService.get(mContext);
|
||||||
|
|
||||||
|
final Song[] songs = { service.getSong(-1), service.getSong(0), service.getSong(1) };
|
||||||
|
final int len = songs.length;
|
||||||
|
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
Song song = songs[i];
|
||||||
|
if (mBitmapBucket.getSong(i) != song) {
|
||||||
|
mHandler.sendMessage(mHandler.obtainMessage(MSG_SET_BITMAP, i, 0, song));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the cover in bitmap bucket for given index.
|
||||||
|
*
|
||||||
|
* @param i the index to modify
|
||||||
|
* @param song the source of the cover
|
||||||
|
*/
|
||||||
|
private void setSongBitmap(int i, Song song) {
|
||||||
|
Bitmap bitmap = mBitmapBucket.grepBitmap(song);
|
||||||
|
|
||||||
|
if (bitmap == null)
|
||||||
|
bitmap = generateBitmap(song);
|
||||||
|
|
||||||
|
mBitmapBucket.setSongBitmap(i, song, bitmap);
|
||||||
|
postInvalidateOnAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a correctly sized cover bitmap for given song
|
||||||
|
*/
|
||||||
|
private Bitmap generateBitmap(Song song) {
|
||||||
|
int style = mCoverStyle;
|
||||||
|
Bitmap cover = song == null ? null : song.getCover(mContext);
|
||||||
|
|
||||||
|
if (cover == null && style != CoverBitmap.STYLE_OVERLAPPING_BOX) {
|
||||||
|
cover = CoverBitmap.generateDefaultCover(mContext, getWidth(), getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoverBitmap.createBitmap(mContext, style, cover, song, getWidth(), getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private final static int MSG_QUERY_SONGS = 1;
|
||||||
|
private final static int MSG_LONG_CLICK = 2;
|
||||||
|
private final static int MSG_SHIFT_SONG = 3;
|
||||||
|
private final static int MSG_SET_BITMAP = 4;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight)
|
public boolean handleMessage(Message message) {
|
||||||
{
|
switch (message.what) {
|
||||||
|
case MSG_QUERY_SONGS:
|
||||||
|
querySongsInternal();
|
||||||
|
break;
|
||||||
|
case MSG_LONG_CLICK:
|
||||||
|
if (scrollIsNotSignificant()) {
|
||||||
|
performLongClick();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case MSG_SHIFT_SONG:
|
||||||
|
DEBUG("Shifting to song: "+message.arg1);
|
||||||
|
mCallback.shiftCurrentSong(message.arg1);
|
||||||
|
break;
|
||||||
|
case MSG_SET_BITMAP:
|
||||||
|
setSongBitmap(message.arg1, (Song)message.obj);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Unknown message received: "+message.what);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers if the view changes its size, may call querySongs() if it was called
|
||||||
|
* previously but had to be aborted as the view was not yet laid out.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
|
||||||
if (mPendingQuery && width != 0 && height != 0) {
|
if (mPendingQuery && width != 0 && height != 0) {
|
||||||
mPendingQuery = false;
|
mPendingQuery = false;
|
||||||
querySongs(PlaybackService.get(getContext()));
|
querySongs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out this view - only handles our specific use cases
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,309 +298,357 @@ public final class CoverView extends View implements Handler.Callback {
|
|||||||
* Paint the cover art views to the canvas.
|
* Paint the cover art views to the canvas.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void onDraw(Canvas canvas)
|
protected void onDraw(Canvas canvas) {
|
||||||
{
|
|
||||||
int width = getWidth();
|
int width = getWidth();
|
||||||
int height = getHeight();
|
int height = getHeight();
|
||||||
int x = 0;
|
int x = 0;
|
||||||
int scrollX = mScrollX;
|
int scrollX = mScrollX;
|
||||||
double padding = 14 * sDensity;
|
double padding = 14 * sDensity;
|
||||||
|
boolean snapshot = !mScroller.isFinished();
|
||||||
|
Bitmap bitmap;
|
||||||
|
|
||||||
for (Bitmap bitmap : mActiveBitmaps) {
|
for (int i=0; i <= 2 ; i++) {
|
||||||
|
bitmap = snapshot ? mBitmapBucket.getSnapshot(i) : mBitmapBucket.getBitmap(i);
|
||||||
if (bitmap != null && scrollX + width > x && scrollX < x + width) {
|
if (bitmap != null && scrollX + width > x && scrollX < x + width) {
|
||||||
int xOffset = (width - bitmap.getWidth()) / 2;
|
final int xOffset = (width - bitmap.getWidth()) / 2;
|
||||||
int yOffset = (int)(padding + (height - bitmap.getHeight()) / 2);
|
final int yOffset = (int)(padding + (height - bitmap.getHeight()) / 2);
|
||||||
canvas.drawBitmap(bitmap, x + xOffset - scrollX, yOffset, null);
|
canvas.drawBitmap(bitmap, x + xOffset - scrollX, yOffset, null);
|
||||||
}
|
}
|
||||||
x += width;
|
x += width;
|
||||||
}
|
}
|
||||||
|
advanceScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls the view when dragged. Animates a fling to one of the three covers
|
* Advances to the next frame of the animation.
|
||||||
* 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.
|
private void advanceScroll() {
|
||||||
*
|
boolean running = mScroller.computeScrollOffset();
|
||||||
* Also performs a click on the view when it is tapped without dragging.
|
if (running) {
|
||||||
|
mScrollX = mScroller.getCurrX();
|
||||||
|
if (mScroller.isFinished()) {
|
||||||
|
// just hit the end!
|
||||||
|
mBitmapBucket.finalizeScroll();
|
||||||
|
mScrollX = getWidth();
|
||||||
|
|
||||||
|
int coverIntent = mScroller.getCoverIntent();
|
||||||
|
if (coverIntent != 0 && ASYNC_SWITCH < 0)
|
||||||
|
mHandler.sendMessage(mHandler.obtainMessage(MSG_SHIFT_SONG, coverIntent, 0));
|
||||||
|
|
||||||
|
DEBUG("Scroll finished, invalidating all snapshot bitmaps!");
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all touch events received by this view.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean onTouchEvent(MotionEvent ev)
|
public boolean onTouchEvent(MotionEvent ev) {
|
||||||
{
|
|
||||||
if (mVelocityTracker == null)
|
|
||||||
mVelocityTracker = VelocityTracker.obtain();
|
|
||||||
mVelocityTracker.addMovement(ev);
|
|
||||||
|
|
||||||
float x = ev.getX();
|
float x = ev.getX();
|
||||||
float y = ev.getY();
|
float y = ev.getY();
|
||||||
int scrollX = mScrollX;
|
int scrollX = mScrollX;
|
||||||
int width = getWidth();
|
int width = getWidth();
|
||||||
|
boolean invalidate = false;
|
||||||
|
|
||||||
|
if (mVelocityTracker == null)
|
||||||
|
mVelocityTracker = VelocityTracker.obtain();
|
||||||
|
mVelocityTracker.addMovement(ev);
|
||||||
|
|
||||||
switch (ev.getAction()) {
|
switch (ev.getAction()) {
|
||||||
case MotionEvent.ACTION_DOWN:
|
case MotionEvent.ACTION_DOWN: {
|
||||||
if (!mScroller.isFinished()) {
|
|
||||||
mScroller.abortAnimation();
|
|
||||||
mActiveBitmaps = mBitmaps;
|
|
||||||
}
|
|
||||||
|
|
||||||
mStartX = x;
|
if (mScroller.isFinished()) {
|
||||||
mStartY = y;
|
mHandler.sendEmptyMessageDelayed(MSG_LONG_CLICK, ViewConfiguration.getLongPressTimeout());
|
||||||
mLastMotionX = x;
|
} else {
|
||||||
mLastMotionY = y;
|
// Animation was still running while we got a new down event
|
||||||
mScrolling = true;
|
// Abort the current animation!
|
||||||
|
|
||||||
mUiHandler.sendEmptyMessageDelayed(MSG_LONG_CLICK, ViewConfiguration.getLongPressTimeout());
|
final int coverIntent = mScroller.getCoverIntent();
|
||||||
break;
|
mBitmapBucket.abortScroll();
|
||||||
case MotionEvent.ACTION_MOVE: {
|
mScroller.abortAnimation();
|
||||||
float deltaX = mLastMotionX - x;
|
|
||||||
float deltaY = mLastMotionY - y;
|
|
||||||
|
|
||||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
if (coverIntent != 0) {
|
||||||
if (deltaX < 0) {
|
// The running animation was actually supposed to switch to a new song.
|
||||||
int availableToScroll = scrollX - (mSongs[0] == null ? width : 0);
|
// Do this right now as the animation is canceled:
|
||||||
if (availableToScroll > 0) {
|
// First, set our non-cached covers to a sane version
|
||||||
mScrollX += Math.max(-availableToScroll, (int)deltaX);
|
mBitmapBucket.prepareScroll(coverIntent);
|
||||||
invalidate();
|
// ..and fix up the scrolling position.
|
||||||
|
mScrollX -= coverIntent * getWidth();
|
||||||
|
// all done: we can now trigger the song jump
|
||||||
|
if (ASYNC_SWITCH < 0 || mHandler.hasMessages(MSG_SHIFT_SONG)) {
|
||||||
|
mHandler.removeMessages(MSG_SHIFT_SONG);
|
||||||
|
mHandler.sendMessage(mHandler.obtainMessage(MSG_SHIFT_SONG, coverIntent, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (deltaX > 0) {
|
|
||||||
int availableToScroll = width * 2 - scrollX;
|
// There is no running animation (anymore?), so we can drop the cache.
|
||||||
if (availableToScroll > 0) {
|
mBitmapBucket.finalizeScroll();
|
||||||
mScrollX += Math.min(availableToScroll, (int)deltaX);
|
}
|
||||||
invalidate();
|
|
||||||
|
mLastMotionX = mInitialMotionX = x;
|
||||||
|
mLastMotionY = mInitialMotionY = y;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MotionEvent.ACTION_MOVE: {
|
||||||
|
final float deltaX = mLastMotionX - x;
|
||||||
|
final float deltaY = mLastMotionY - y;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY)) { // only change X if the fling is horizontal
|
||||||
|
if (deltaX < 0) {
|
||||||
|
int availableToScroll = scrollX - (mBitmapBucket.getSong(0) == null ? width : 0);
|
||||||
|
if (availableToScroll > 0) {
|
||||||
|
mScrollX += Math.max(-availableToScroll, (int)deltaX);
|
||||||
|
invalidate = true;
|
||||||
|
}
|
||||||
|
} else if (deltaX > 0) {
|
||||||
|
int availableToScroll = width * 2 - scrollX;
|
||||||
|
if (availableToScroll > 0) {
|
||||||
|
mScrollX += Math.min(availableToScroll, (int)deltaX);
|
||||||
|
invalidate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mLastMotionX = x;
|
||||||
|
mLastMotionY = y;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
case MotionEvent.ACTION_UP: {
|
||||||
mLastMotionX = x;
|
VelocityTracker velocityTracker = mVelocityTracker;
|
||||||
mLastMotionY = y;
|
velocityTracker.computeCurrentVelocity(1000); // report velocity in pixels-per-second, as assumed by snap-velocity and co.
|
||||||
break;
|
final int velocityX = (int) velocityTracker.getXVelocity();
|
||||||
}
|
final int velocityY = (int) velocityTracker.getYVelocity();
|
||||||
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.recycle();
|
||||||
mVelocityTracker = null;
|
mVelocityTracker = null;
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
final int mvx = Math.abs(velocityX);
|
||||||
}
|
final int mvy = Math.abs(velocityY);
|
||||||
|
final float distanceX = mLastMotionX - mInitialMotionX;
|
||||||
|
int whichCover = 0;
|
||||||
|
|
||||||
|
if (scrollIsNotSignificant()) {
|
||||||
|
if (mHandler.hasMessages(MSG_LONG_CLICK)) {
|
||||||
|
// long click didn't fire yet -> consider this to be a normal click
|
||||||
|
performClick();
|
||||||
|
}
|
||||||
|
} else if (Math.abs(distanceX) > width/2) {
|
||||||
|
whichCover = distanceX < 0 ? 1 : -1;
|
||||||
|
} else if (mvx > sSnapVelocity || mvy > sSnapVelocity) {
|
||||||
|
if (mvy > mvx) {
|
||||||
|
if (velocityY > 0)
|
||||||
|
mCallback.downSwipe();
|
||||||
|
else
|
||||||
|
mCallback.upSwipe();
|
||||||
|
} else {
|
||||||
|
whichCover = velocityX < 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that the target song actually exists.
|
||||||
|
// Eg: We may not have song 0 in random mode.
|
||||||
|
if (mBitmapBucket.getSong(1+whichCover) == null)
|
||||||
|
whichCover = 0;
|
||||||
|
|
||||||
|
final int scrollTargetX = width + whichCover*width;
|
||||||
|
|
||||||
|
mBitmapBucket.prepareScroll(whichCover);
|
||||||
|
mScroller.handleFling(velocityX, mScrollX, scrollTargetX, whichCover);
|
||||||
|
if (ASYNC_SWITCH >= 0)
|
||||||
|
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_SHIFT_SONG, whichCover, 0), ASYNC_SWITCH);
|
||||||
|
mHandler.removeMessages(MSG_LONG_CLICK);
|
||||||
|
|
||||||
|
invalidate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invalidate)
|
||||||
|
postInvalidateOnAnimation();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a bitmap for the given song.
|
* Returns true if the scroll traveled a significant distance
|
||||||
*
|
* and is therefore not considered to be random noise during a click.
|
||||||
* @param i The position of the song in mSongs.
|
|
||||||
*/
|
*/
|
||||||
private void generateBitmap(int i)
|
private boolean scrollIsNotSignificant() {
|
||||||
{
|
final float distanceX = mLastMotionX - mInitialMotionX;
|
||||||
if(getWidth() == 0 || getHeight() == 0) {
|
final float distanceY = mLastMotionY - mInitialMotionY;
|
||||||
// View isn't laid out - can't generate the bitmap until we know the size
|
return Math.abs(distanceX) + Math.abs(distanceY) < TOUCH_MAX_SCROLL_PX;
|
||||||
mPendingQuery = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Song song = mSongs[i];
|
|
||||||
|
|
||||||
int style = mCoverStyle;
|
|
||||||
Context context = getContext();
|
|
||||||
Bitmap cover = song == null ? null : song.getCover(context);
|
|
||||||
|
|
||||||
if (cover == null && style != CoverBitmap.STYLE_OVERLAPPING_BOX) {
|
|
||||||
if (mDefaultCover == null) {
|
|
||||||
mDefaultCover = CoverBitmap.generateDefaultCover(context, getWidth(), getHeight());
|
|
||||||
}
|
|
||||||
cover = mDefaultCover;
|
|
||||||
}
|
|
||||||
|
|
||||||
mBitmaps[i] = CoverBitmap.createBitmap(context, style, cover, song, getWidth(), getHeight());
|
|
||||||
postInvalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void DEBUG(String s) {
|
||||||
* Set the Song at position <code>i</code> to <code>song</code>, generating
|
// Log.v("VanillaMusicCover", s);
|
||||||
* 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.
|
* Class to handle access to our bitmap and song mapping
|
||||||
*
|
|
||||||
* @param service Service to query from.
|
|
||||||
*/
|
*/
|
||||||
public void querySongs(PlaybackService service)
|
private class BitmapBucket {
|
||||||
{
|
/**
|
||||||
if (getWidth() == 0 || getHeight() == 0) {
|
* The pre-generated bitmaps for all 3 songs
|
||||||
mPendingQuery = true;
|
*/
|
||||||
return;
|
private final Bitmap[] mCacheBitmaps = new Bitmap[3];
|
||||||
|
/**
|
||||||
|
* Cached songs, used to check if mCacheBitmaps is still valid
|
||||||
|
*/
|
||||||
|
private final Song[] mCacheSongs = new Song[3];
|
||||||
|
/**
|
||||||
|
* A WIP copy: We use this (iff available) to draw.
|
||||||
|
* This allows us to update mCacheBitmaps while scrolling
|
||||||
|
*/
|
||||||
|
private final Bitmap[] mSnapshotBitmaps = new Bitmap[3];
|
||||||
|
/**
|
||||||
|
* A WIP copy of songs, used if we have to restore
|
||||||
|
* This snapshot is only used internally and copied
|
||||||
|
* to mCacheBitmaps if we restore our bitmap snapshot.
|
||||||
|
*/
|
||||||
|
private final Song[] mSnapshotSongs = new Song[3];
|
||||||
|
/**
|
||||||
|
* Constructor for BitmapBucket
|
||||||
|
*/
|
||||||
|
public BitmapBucket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
mHandler.removeMessages(MSG_GENERATE_BITMAP);
|
public Song getSong(int i) {
|
||||||
|
return mCacheSongs[i];
|
||||||
|
}
|
||||||
|
|
||||||
Song[] songs = mSongs;
|
public Bitmap getBitmap(int i) {
|
||||||
Bitmap[] bitmaps = mBitmaps;
|
return mCacheBitmaps[i];
|
||||||
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) {
|
public Bitmap getSnapshot(int i) {
|
||||||
if (newSongs[i] == null)
|
return mSnapshotBitmaps[i];
|
||||||
continue;
|
}
|
||||||
|
|
||||||
for (int j = 0; j != 3; ++j) {
|
public void setSongBitmap(int i, Song song, Bitmap bitmap) {
|
||||||
if (newSongs[i] == songs[j]) {
|
mCacheSongs[i] = song;
|
||||||
newBitmaps[i] = bitmaps[j];
|
mCacheBitmaps[i] = bitmap;
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
public Bitmap grepBitmap(Song song) {
|
||||||
|
final int len = mCacheSongs.length;
|
||||||
|
for (int i = 0; i < len ; i++) {
|
||||||
|
if (mCacheSongs[i] == song) {
|
||||||
|
return mCacheBitmaps[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (newBitmaps[i] == null) {
|
/**
|
||||||
mHandler.sendMessage(mHandler.obtainMessage(MSG_GENERATE_BITMAP, i, 0));
|
* Hint that we are going to scroll.
|
||||||
|
* This causes us to populate our bitmap snapshot
|
||||||
|
* and will cause us to modify the current cache with a guess.
|
||||||
|
*
|
||||||
|
* @param futureCover the cover we are going to scroll to
|
||||||
|
*/
|
||||||
|
public void prepareScroll(int futureCover) {
|
||||||
|
// Grab a snapshot of the bitmaps which will be used
|
||||||
|
// while the animation is running, so that we can concurrently
|
||||||
|
// modify mCacheBitmaps.
|
||||||
|
System.arraycopy(mCacheBitmaps, 0, mSnapshotBitmaps, 0, 3);
|
||||||
|
System.arraycopy(mCacheSongs, 0, mSnapshotSongs, 0, 3);
|
||||||
|
|
||||||
|
// we are going to scroll, so most likely we can save 2 bitmaps by guessing the
|
||||||
|
// new situation. This doesn't have to be 100% correct as the next querySongs()
|
||||||
|
// call would fix up wrong guesses.
|
||||||
|
// FIXME: This may be under-locked: cache checks via getSong() and prepareScroll() may race.
|
||||||
|
if (futureCover > 0) {
|
||||||
|
mCacheBitmaps[0] = mCacheBitmaps[1];
|
||||||
|
mCacheSongs[0] = mCacheSongs[1];
|
||||||
|
mCacheBitmaps[1] = mCacheBitmaps[2];
|
||||||
|
mCacheSongs[1] = mCacheSongs[2];
|
||||||
|
mCacheBitmaps[2] = null;
|
||||||
|
mCacheSongs[2] = new Song(-1);
|
||||||
|
} else if (futureCover < 0) {
|
||||||
|
mCacheBitmaps[2] = mCacheBitmaps[1];
|
||||||
|
mCacheSongs[2] = mCacheSongs[1];
|
||||||
|
mCacheBitmaps[1] = mCacheBitmaps[0];
|
||||||
|
mCacheSongs[1] = mCacheSongs[0];
|
||||||
|
mCacheBitmaps[0] = null;
|
||||||
|
mCacheSongs[0] = new Song(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetScroll();
|
/**
|
||||||
}
|
* Abort a scroll initiated by prepareScroll.
|
||||||
|
*/
|
||||||
/**
|
public void abortScroll() {
|
||||||
* Call {@link CoverView#generateBitmap(int)} for the song at the given index.
|
// undo our guess we did in prepareScroll
|
||||||
*
|
System.arraycopy(mSnapshotBitmaps, 0, mCacheBitmaps, 0, 3);
|
||||||
* arg1 should be the index of the song.
|
System.arraycopy(mSnapshotSongs, 0, mCacheSongs, 0, 3);
|
||||||
*/
|
finalizeScroll();
|
||||||
private static final int MSG_GENERATE_BITMAP = 0;
|
}
|
||||||
/**
|
|
||||||
* Perform a long click.
|
public void finalizeScroll() {
|
||||||
*
|
for (int i=0; i <= 2; i++) {
|
||||||
* @see View#performLongClick()
|
mSnapshotBitmaps[i] = null;
|
||||||
*/
|
mSnapshotSongs[i] = null;
|
||||||
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)
|
* The scroller class helps to keep track
|
||||||
{
|
* of the current scroll progress.
|
||||||
// This implementation only tries to handle two cases: use in the
|
*/
|
||||||
// FullPlaybackActivity, where we want to fill the whole screen,
|
private class CoverScroller extends Scroller {
|
||||||
// and use in the MiniPlaybackActivity, where we want to be square.
|
/**
|
||||||
|
* The cover we are scrolling to
|
||||||
|
*/
|
||||||
|
private int mCoverIntent;
|
||||||
|
/**
|
||||||
|
* Returns a new scroller instance
|
||||||
|
*/
|
||||||
|
public CoverScroller(Context context) {
|
||||||
|
super(context, new LinearInterpolator(), false);
|
||||||
|
}
|
||||||
|
|
||||||
int width = View.MeasureSpec.getSize(widthSpec);
|
/**
|
||||||
int height = View.MeasureSpec.getSize(heightSpec);
|
* Returns the cover set by the last handleFling
|
||||||
|
* call.
|
||||||
|
*
|
||||||
|
* @return int the cover set by handleFling.
|
||||||
|
*/
|
||||||
|
public int getCoverIntent() {
|
||||||
|
return mCoverIntent;
|
||||||
|
}
|
||||||
|
|
||||||
if (View.MeasureSpec.getMode(widthSpec) == View.MeasureSpec.EXACTLY
|
@Override
|
||||||
&& View.MeasureSpec.getMode(heightSpec) == View.MeasureSpec.EXACTLY) {
|
public void abortAnimation() {
|
||||||
// FullPlaybackActivity: fill screen
|
mCoverIntent = 0;
|
||||||
setMeasuredDimension(width, height);
|
super.abortAnimation();
|
||||||
} else {
|
}
|
||||||
// MiniPlaybackActivity: be square
|
/**
|
||||||
int size = Math.min(width, height);
|
* Starts a fling operation
|
||||||
setMeasuredDimension(size, size);
|
*
|
||||||
|
* @param velocity the current velocity
|
||||||
|
* @param from the current x coordinate
|
||||||
|
* @param to the target x coordinate
|
||||||
|
* @param coverIntent the cover we are scrolling to, returned by getCoverIntent()
|
||||||
|
*/
|
||||||
|
public void handleFling(int velocity, int from, int to, int coverIntent) {
|
||||||
|
if (!isFinished()) {
|
||||||
|
abortAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
mCoverIntent = coverIntent;
|
||||||
|
|
||||||
|
final int distance = to - from;
|
||||||
|
int duration = (int)(Math.abs(distance) / sDensity);
|
||||||
|
if (duration > 200)
|
||||||
|
duration = 200;
|
||||||
|
|
||||||
|
startScroll(from, 0, distance, 0, duration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -323,7 +323,7 @@ public abstract class PlaybackActivity extends Activity
|
|||||||
protected void onSongChange(Song song)
|
protected void onSongChange(Song song)
|
||||||
{
|
{
|
||||||
if (mCoverView != null)
|
if (mCoverView != null)
|
||||||
mCoverView.querySongs(PlaybackService.get(this));
|
mCoverView.querySongs();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setSong(final Song song)
|
protected void setSong(final Song song)
|
||||||
@ -375,7 +375,7 @@ public abstract class PlaybackActivity extends Activity
|
|||||||
public void replaceSong(int delta, Song song)
|
public void replaceSong(int delta, Song song)
|
||||||
{
|
{
|
||||||
if (mCoverView != null)
|
if (mCoverView != null)
|
||||||
mCoverView.setSong(delta + 1, song);
|
mCoverView.replaceSong(delta, song);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
x
Reference in New Issue
Block a user