From 963653311eac17ad0822462014580a26e31ccc95 Mon Sep 17 00:00:00 2001 From: linus_lee Date: Thu, 18 Sep 2014 17:18:38 -0700 Subject: Eleven: This adds the playlist top artist and song artwork logic Reshuffles a lot of code from ImageWorker/ImageFetcher into ImageUtils and Separate worker tasks. The artwork logic leverages the top songs db to figure the top artist as well as the top songs for a playlist and builds images based on them. There is some basic logic to prevent this code from running all the time https://cyanogen.atlassian.net/browse/MUSIC-27 Change-Id: I05d832d4f0810b71ed0c0df184a372771537ccc1 --- res/drawable-xxhdpi/recently_added.png | Bin 0 -> 619 bytes src/com/cyngn/eleven/adapters/PlaylistAdapter.java | 15 +- src/com/cyngn/eleven/cache/BitmapWorkerTask.java | 120 +++++ .../cyngn/eleven/cache/BlurBitmapWorkerTask.java | 221 ++++++++ src/com/cyngn/eleven/cache/ImageCache.java | 34 +- src/com/cyngn/eleven/cache/ImageFetcher.java | 242 +-------- src/com/cyngn/eleven/cache/ImageWorker.java | 563 +++++---------------- src/com/cyngn/eleven/cache/PlaylistWorkerTask.java | 332 ++++++++++++ .../cyngn/eleven/cache/SimpleBitmapWorkerTask.java | 55 ++ src/com/cyngn/eleven/loaders/SortedCursor.java | 125 +++++ src/com/cyngn/eleven/loaders/TopTracksCursor.java | 118 ----- src/com/cyngn/eleven/loaders/TopTracksLoader.java | 4 +- .../eleven/provider/PlaylistArtworkStore.java | 258 ++++++++++ src/com/cyngn/eleven/provider/SongPlayCount.java | 83 +++ src/com/cyngn/eleven/recycler/RecycleHolder.java | 2 + .../ui/activities/PlaylistDetailActivity.java | 4 +- .../eleven/ui/activities/ProfileActivity.java | 3 +- src/com/cyngn/eleven/utils/ImageUtils.java | 241 +++++++++ 18 files changed, 1633 insertions(+), 787 deletions(-) create mode 100644 res/drawable-xxhdpi/recently_added.png create mode 100644 src/com/cyngn/eleven/cache/BitmapWorkerTask.java create mode 100644 src/com/cyngn/eleven/cache/BlurBitmapWorkerTask.java create mode 100644 src/com/cyngn/eleven/cache/PlaylistWorkerTask.java create mode 100644 src/com/cyngn/eleven/cache/SimpleBitmapWorkerTask.java create mode 100644 src/com/cyngn/eleven/loaders/SortedCursor.java delete mode 100644 src/com/cyngn/eleven/loaders/TopTracksCursor.java create mode 100644 src/com/cyngn/eleven/provider/PlaylistArtworkStore.java create mode 100644 src/com/cyngn/eleven/utils/ImageUtils.java diff --git a/res/drawable-xxhdpi/recently_added.png b/res/drawable-xxhdpi/recently_added.png new file mode 100644 index 0000000..e626b05 Binary files /dev/null and b/res/drawable-xxhdpi/recently_added.png differ diff --git a/src/com/cyngn/eleven/adapters/PlaylistAdapter.java b/src/com/cyngn/eleven/adapters/PlaylistAdapter.java index cff604c..6dd742f 100644 --- a/src/com/cyngn/eleven/adapters/PlaylistAdapter.java +++ b/src/com/cyngn/eleven/adapters/PlaylistAdapter.java @@ -21,6 +21,7 @@ import android.widget.ArrayAdapter; import com.cyngn.eleven.Config.SmartPlaylistType; import com.cyngn.eleven.R; +import com.cyngn.eleven.cache.ImageFetcher; import com.cyngn.eleven.model.Playlist; import com.cyngn.eleven.ui.MusicHolder; import com.cyngn.eleven.ui.MusicHolder.DataHolder; @@ -92,10 +93,13 @@ public class PlaylistAdapter extends ArrayAdapter { SmartPlaylistType type = SmartPlaylistType.getTypeById(dataHolder.mItemId); if (type != null) { + // Clear any drawables + holder.mImage.get().setBackground(null); + + // Set the image resource based on the icon switch (type) { case LastAdded: - // TOOD: Replace with Last Added Icon - holder.mImage.get().setImageResource(R.drawable.recent_icon); + holder.mImage.get().setImageResource(R.drawable.recently_added); break; case TopTracks: default: @@ -103,10 +107,15 @@ public class PlaylistAdapter extends ArrayAdapter { break; } + // set the special background color convertView.setBackgroundColor(getContext().getResources(). getColor(R.color.smart_playlist_item_background)); } else { - holder.mImage.get().setImageResource(R.drawable.default_playlist); + // load the image + ImageFetcher.getInstance(getContext()).loadPlaylistCoverArtImage( + dataHolder.mItemId, holder.mImage.get()); + + // clear the background color convertView.setBackgroundColor(Color.TRANSPARENT); } diff --git a/src/com/cyngn/eleven/cache/BitmapWorkerTask.java b/src/com/cyngn/eleven/cache/BitmapWorkerTask.java new file mode 100644 index 0000000..aa354f4 --- /dev/null +++ b/src/com/cyngn/eleven/cache/BitmapWorkerTask.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.AsyncTask; +import android.widget.ImageView; + +import com.cyngn.eleven.cache.ImageWorker.ImageType; + +import java.lang.ref.WeakReference; + +/** + * The actual {@link android.os.AsyncTask} that will process the image. + */ +public abstract class BitmapWorkerTask + extends AsyncTask { + /** + * The {@link android.widget.ImageView} used to set the result + */ + protected final WeakReference mImageReference; + + /** + * Type of URL to download + */ + protected final ImageWorker.ImageType mImageType; + + /** + * Layer drawable used to cross fade the result from the worker + */ + protected Drawable mFromDrawable; + + protected final Context mContext; + + protected final ImageCache mImageCache; + + protected final Resources mResources; + + /** + * The key used to store cached entries + */ + public String mKey; + + /** + * Constructor of BitmapWorkerTask + * @param key used for caching the image + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public BitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context) { + mKey = key; + + mContext = context; + mImageCache = ImageCache.getInstance(mContext); + mResources = mContext.getResources(); + + mImageReference = new WeakReference(imageView); + mImageType = imageType; + + // A transparent image (layer 0) and the new result (layer 1) + mFromDrawable = fromDrawable; + } + + /** + * @return The {@link ImageView} associated with this task as long as + * the ImageView's task still points to this task as well. + * Returns null otherwise. + */ + protected ImageView getAttachedImageView() { + final ImageView imageView = mImageReference.get(); + if (imageView != null) { + final BitmapWorkerTask bitmapWorkerTask = ImageWorker.getBitmapWorkerTask(imageView); + if (this == bitmapWorkerTask) { + return imageView; + } + } + + return null; + } + + /** + * Gets the bitmap given the input params + * @param params artistName, albumName, albumId + * @return Bitmap + */ + protected Bitmap getBitmapInBackground(final String... params) { + return ImageWorker.getBitmapInBackground(mContext, mImageCache, mKey, + params[1], params[0], Long.valueOf(params[2]), mImageType); + } + + /** + * Creates a transition drawable with default parameters + * @param bitmap the bitmap to transition to + * @return the transition drawable + */ + protected TransitionDrawable createImageTransitionDrawable(final Bitmap bitmap) { + return createImageTransitionDrawable(bitmap, ImageWorker.FADE_IN_TIME, false, false); + } + + /** + * Creates a transition drawable + * @param bitmap to transition to + * @param fadeTime the time to fade in ms + * @param dither setting + * @param force force create a transition even if bitmap == null (fade to transparent) + * @return the transition drawable + */ + protected TransitionDrawable createImageTransitionDrawable(final Bitmap bitmap, + final int fadeTime, final boolean dither, final boolean force) { + return ImageWorker.createImageTransitionDrawable(mResources, mFromDrawable, bitmap, + fadeTime, dither, force); + } +} diff --git a/src/com/cyngn/eleven/cache/BlurBitmapWorkerTask.java b/src/com/cyngn/eleven/cache/BlurBitmapWorkerTask.java new file mode 100644 index 0000000..ac4cc59 --- /dev/null +++ b/src/com/cyngn/eleven/cache/BlurBitmapWorkerTask.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.support.v7.graphics.Palette; +import android.support.v7.graphics.PaletteItem; +import android.support.v8.renderscript.Allocation; +import android.support.v8.renderscript.Element; +import android.support.v8.renderscript.RenderScript; +import android.support.v8.renderscript.ScriptIntrinsicBlur; +import android.widget.ImageView; + +import com.cyngn.eleven.cache.ImageWorker.ImageType; +import com.cyngn.eleven.widgets.BlurScrimImage; + +import java.lang.ref.WeakReference; + +/** + * This will download the image (if needed) and create a blur and set the scrim as well on the + * BlurScrimImage + */ +public class BlurBitmapWorkerTask extends BitmapWorkerTask { + // if the image is too small, the blur will look bad post scale up so we use the min size + // to scale up before bluring + private static final int MIN_BITMAP_SIZE = 500; + // number of times to run the blur + private static final int NUM_BLUR_RUNS = 8; + // 25f is the max blur radius possible + private static final float BLUR_RADIUS = 25f; + + // container for the result + public static class ResultContainer { + public TransitionDrawable mImageViewBitmapDrawable; + public int mPaletteColor; + } + + /** + * The {@link com.cyngn.eleven.widgets.BlurScrimImage} used to set the result + */ + private final WeakReference mBlurScrimImage; + + /** + * RenderScript used to blur the image + */ + protected final RenderScript mRenderScript; + + /** + * Constructor of BlurBitmapWorkerTask + * @param key used for caching the image + * @param blurScrimImage The {@link BlurScrimImage} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public BlurBitmapWorkerTask(final String key, final BlurScrimImage blurScrimImage, + final ImageType imageType, final Drawable fromDrawable, + final Context context, final RenderScript renderScript) { + super(key, blurScrimImage.getImageView(), imageType, fromDrawable, context); + mBlurScrimImage = new WeakReference(blurScrimImage); + mRenderScript = renderScript; + + // use the existing image as the drawable and if it doesn't exist fallback to transparent + mFromDrawable = blurScrimImage.getImageView().getDrawable(); + if (mFromDrawable == null) { + mFromDrawable = fromDrawable; + } + } + + /** + * {@inheritDoc} + */ + @Override + protected ResultContainer doInBackground(final String... params) { + if (isCancelled()) { + return null; + } + + Bitmap bitmap = getBitmapInBackground(params); + + ResultContainer result = new ResultContainer(); + + Bitmap output = null; + + if (bitmap != null) { + // now create the blur bitmap + Bitmap input = bitmap; + + // if the image is too small, scale it up before running through the blur + if (input.getWidth() < MIN_BITMAP_SIZE || input.getHeight() < MIN_BITMAP_SIZE) { + float multiplier = Math.max(MIN_BITMAP_SIZE / (float)input.getWidth(), + MIN_BITMAP_SIZE / (float)input.getHeight()); + input = input.createScaledBitmap(bitmap, (int)(input.getWidth() * multiplier), + (int)(input.getHeight() * multiplier), true); + // since we created a new bitmap, we can re-use the bitmap for our output + output = input; + } else { + // if we aren't creating a new bitmap, create a new output bitmap + output = Bitmap.createBitmap(input.getWidth(), input.getHeight(), input.getConfig()); + } + + // run the blur multiple times + for (int i = 0; i < NUM_BLUR_RUNS; i++) { + final Allocation inputAlloc = Allocation.createFromBitmap(mRenderScript, input); + final Allocation outputAlloc = Allocation.createTyped(mRenderScript, + inputAlloc.getType()); + final ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(mRenderScript, + Element.U8_4(mRenderScript)); + + script.setRadius(BLUR_RADIUS); + script.setInput(inputAlloc); + script.forEach(outputAlloc); + outputAlloc.copyTo(output); + + // if we run more than 1 blur, the new input should be the old output + input = output; + } + + // calculate the palette color + result.mPaletteColor = getPaletteColorInBackground(output); + + // create the bitmap transition drawable + result.mImageViewBitmapDrawable = createImageTransitionDrawable(output, + ImageWorker.FADE_IN_TIME_SLOW, true, true); + + return result; + } + + return null; + } + + /** + * This will get the most vibrant palette color for a bitmap + * @param input to process + * @return the most vibrant color or transparent if none found + */ + private int getPaletteColorInBackground(Bitmap input) { + int color = Color.TRANSPARENT; + + if (input != null) { + Palette palette = Palette.generate(input); + PaletteItem paletteItem = palette.getVibrantColor(); + + // keep walking through the palette items to find a color if we don't have any + if (paletteItem == null) { + paletteItem = palette.getLightVibrantColor(); + } + + if (paletteItem == null) { + paletteItem = palette.getLightMutedColor(); + } + + if (paletteItem == null) { + paletteItem = palette.getLightMutedColor(); + } + + if (paletteItem == null) { + paletteItem = palette.getDarkVibrantColor(); + } + + if (paletteItem == null) { + paletteItem = palette.getMutedColor(); + } + + if (paletteItem == null) { + paletteItem = palette.getDarkMutedColor(); + } + + if (paletteItem != null) { + // grab the rgb values + color = paletteItem.getRgb() | 0xFFFFFF; + + // make it 20% opacity + color &= 0x33000000; + } + } + + return color; + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(ResultContainer resultContainer) { + BlurScrimImage blurScrimImage = mBlurScrimImage.get(); + if (blurScrimImage != null) { + if (resultContainer == null) { + // if we have no image, then signal the transition to the default state + blurScrimImage.transitionToDefaultState(); + } else { + // create the palette transition + TransitionDrawable paletteTransition = ImageWorker.createPaletteTransition( + blurScrimImage, + resultContainer.mPaletteColor); + + // set the transition drawable + blurScrimImage.setTransitionDrawable(false, + resultContainer.mImageViewBitmapDrawable, paletteTransition); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + protected final ImageView getAttachedImageView() { + final BlurScrimImage blurImage = mBlurScrimImage.get(); + final BitmapWorkerTask bitmapWorkerTask = ImageWorker.getBitmapWorkerTask(blurImage); + if (this == bitmapWorkerTask) { + return blurImage.getImageView(); + } + return null; + } +} \ No newline at end of file diff --git a/src/com/cyngn/eleven/cache/ImageCache.java b/src/com/cyngn/eleven/cache/ImageCache.java index f8b66ac..9bdf9b9 100644 --- a/src/com/cyngn/eleven/cache/ImageCache.java +++ b/src/com/cyngn/eleven/cache/ImageCache.java @@ -271,12 +271,23 @@ public final class ImageCache { * @param bitmap The {@link Bitmap} to cache */ public void addBitmapToCache(final String data, final Bitmap bitmap) { + addBitmapToCache(data, bitmap, false); + } + + /** + * Adds a new image to the memory and disk caches + * + * @param data The key used to store the image + * @param bitmap The {@link Bitmap} to cache + * @param replace force a replace even if the bitmap exists in the cache + */ + public void addBitmapToCache(final String data, final Bitmap bitmap, final boolean replace) { if (data == null || bitmap == null) { return; } // Add to memory cache - addBitmapToMemCache(data, bitmap); + addBitmapToMemCache(data, bitmap, replace); // Add to disk cache if (mDiskCache != null) { @@ -284,7 +295,11 @@ public final class ImageCache { OutputStream out = null; try { final DiskLruCache.Snapshot snapshot = mDiskCache.get(key); - if (snapshot == null) { + if (snapshot != null) { + snapshot.getInputStream(DISK_CACHE_INDEX).close(); + } + + if (snapshot == null || replace) { final DiskLruCache.Editor editor = mDiskCache.edit(key); if (editor != null) { out = editor.newOutputStream(DISK_CACHE_INDEX); @@ -293,8 +308,6 @@ public final class ImageCache { out.close(); flush(); } - } else { - snapshot.getInputStream(DISK_CACHE_INDEX).close(); } } catch (final IOException e) { Log.e(TAG, "addBitmapToCache - " + e); @@ -320,11 +333,22 @@ public final class ImageCache { * @param bitmap The {@link Bitmap} to cache */ public void addBitmapToMemCache(final String data, final Bitmap bitmap) { + addBitmapToMemCache(data, bitmap, false); + } + + /** + * Called to add a new image to the memory cache + * + * @param data The key identifier + * @param bitmap The {@link Bitmap} to cache + * @param replace whether to force a replace if it already exists + */ + public void addBitmapToMemCache(final String data, final Bitmap bitmap, final boolean replace) { if (data == null || bitmap == null) { return; } // Add to memory cache - if (getBitmapFromMemCache(data) == null) { + if (replace || getBitmapFromMemCache(data) == null) { mLruCache.put(data, bitmap); } } diff --git a/src/com/cyngn/eleven/cache/ImageFetcher.java b/src/com/cyngn/eleven/cache/ImageFetcher.java index e647693..3832c6e 100644 --- a/src/com/cyngn/eleven/cache/ImageFetcher.java +++ b/src/com/cyngn/eleven/cache/ImageFetcher.java @@ -13,42 +13,19 @@ package com.cyngn.eleven.cache; import android.content.Context; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.text.TextUtils; import android.widget.ImageView; import com.cyngn.eleven.Config; import com.cyngn.eleven.MusicPlaybackService; -import com.cyngn.eleven.lastfm.Album; -import com.cyngn.eleven.lastfm.Artist; -import com.cyngn.eleven.lastfm.MusicEntry; -import com.cyngn.eleven.lastfm.ImageSize; +import com.cyngn.eleven.cache.PlaylistWorkerTask.PlaylistWorkerType; import com.cyngn.eleven.utils.MusicUtils; -import com.cyngn.eleven.utils.PreferenceUtils; import com.cyngn.eleven.widgets.BlurScrimImage; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; - /** * A subclass of {@link ImageWorker} that fetches images from a URL. */ public class ImageFetcher extends ImageWorker { - public static final int IO_BUFFER_SIZE_BYTES = 1024; - - private static final int DEFAULT_MAX_IMAGE_HEIGHT = 1024; - - private static final int DEFAULT_MAX_IMAGE_WIDTH = 1024; - - private static final String DEFAULT_HTTP_CACHE_DIR = "http"; //$NON-NLS-1$ - private static ImageFetcher sInstance = null; /** @@ -74,79 +51,28 @@ public class ImageFetcher extends ImageWorker { } /** - * {@inheritDoc} + * Loads a playlist's most played song's artist image + * @param playlistId id of the playlist + * @param imageView imageview to load into */ - @Override - protected Bitmap processBitmap(final String url) { - if (url == null) { - return null; - } - final File file = downloadBitmapToFile(mContext, url, DEFAULT_HTTP_CACHE_DIR); - if (file != null) { - // Return a sampled down version - final Bitmap bitmap = decodeSampledBitmapFromFile(file.toString()); - file.delete(); - if (bitmap != null) { - return bitmap; - } - } - return null; - } - - private static String getBestImage(MusicEntry e) { - final ImageSize[] QUALITY = {ImageSize.EXTRALARGE, ImageSize.LARGE, ImageSize.MEDIUM, - ImageSize.SMALL, ImageSize.UNKNOWN}; - for(ImageSize q : QUALITY) { - String url = e.getImageURL(q); - if (url != null) { - return url; - } - } - return null; + public void loadPlaylistArtistImage(final long playlistId, final ImageView imageView) { + loadPlaylistImage(playlistId, PlaylistWorkerType.Artist, imageView); } /** - * {@inheritDoc} + * Loads a playlist's most played songs into a combined image, or show 1 if not enough images + * @param playlistId id of the playlist + * @param imageView imageview to load into */ - @Override - protected String processImageUrl(final String artistName, final String albumName, - final ImageType imageType) { - switch (imageType) { - case ARTIST: - if (!TextUtils.isEmpty(artistName)) { - if (PreferenceUtils.getInstance(mContext).downloadMissingArtistImages()) { - final Artist artist = Artist.getInfo(mContext, artistName); - if (artist != null) { - return getBestImage(artist); - } - } - } - break; - case ALBUM: - if (!TextUtils.isEmpty(artistName) && !TextUtils.isEmpty(albumName)) { - if (PreferenceUtils.getInstance(mContext).downloadMissingArtwork()) { - final Artist correction = Artist.getCorrection(mContext, artistName); - if (correction != null) { - final Album album = Album.getInfo(mContext, correction.getName(), - albumName); - if (album != null) { - return getBestImage(album); - } - } - } - } - break; - default: - break; - } - return null; + public void loadPlaylistCoverArtImage(final long playlistId, final ImageView imageView) { + loadPlaylistImage(playlistId, PlaylistWorkerType.CoverArt, imageView); } /** * Used to fetch album images. */ public void loadAlbumImage(final String artistName, final String albumName, final long albumId, - final ImageView imageView) { + final ImageView imageView) { loadImage(generateAlbumCacheKey(albumName, artistName), artistName, albumName, albumId, imageView, ImageType.ALBUM); } @@ -222,7 +148,7 @@ public class ImageFetcher extends ImageWorker { } /** - * @param keyAlbum The key (album name) used to find the album art to return + * @param keyAlbum The key (album name) used to find the album art to return * @param keyArtist The key (artist name) used to find the album art to return */ public Bitmap getCachedArtwork(final String keyAlbum, final String keyArtist) { @@ -231,12 +157,12 @@ public class ImageFetcher extends ImageWorker { } /** - * @param keyAlbum The key (album name) used to find the album art to return + * @param keyAlbum The key (album name) used to find the album art to return * @param keyArtist The key (artist name) used to find the album art to return - * @param keyId The key (album id) used to find the album art to return + * @param keyId The key (album id) used to find the album art to return */ public Bitmap getCachedArtwork(final String keyAlbum, final String keyArtist, - final long keyId) { + final long keyId) { if (mImageCache != null) { return mImageCache.getCachedArtwork(mContext, generateAlbumCacheKey(keyAlbum, keyArtist), @@ -249,10 +175,10 @@ public class ImageFetcher extends ImageWorker { * Finds cached or downloads album art. Used in {@link MusicPlaybackService} * to set the current album art in the notification and lock screen * - * @param albumName The name of the current album - * @param albumId The ID of the current album + * @param albumName The name of the current album + * @param albumId The ID of the current album * @param artistName The album artist in case we should have to download - * missing artwork + * missing artwork * @return The album art as an {@link Bitmap} */ public Bitmap getArtwork(final String albumName, final long albumId, final String artistName) { @@ -273,140 +199,12 @@ public class ImageFetcher extends ImageWorker { return getDefaultArtwork(); } - /** - * Download a {@link Bitmap} from a URL, write it to a disk and return the - * File pointer. This implementation uses a simple disk cache. - * - * @param context The context to use - * @param urlString The URL to fetch - * @return A {@link File} pointing to the fetched bitmap - */ - public static final File downloadBitmapToFile(final Context context, final String urlString, - final String uniqueName) { - final File cacheDir = ImageCache.getDiskCacheDir(context, uniqueName); - - if (!cacheDir.exists()) { - cacheDir.mkdir(); - } - - HttpURLConnection urlConnection = null; - BufferedOutputStream out = null; - - try { - final File tempFile = File.createTempFile("bitmap", null, cacheDir); //$NON-NLS-1$ - - final URL url = new URL(urlString); - urlConnection = (HttpURLConnection)url.openConnection(); - if (urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) { - return null; - } - final InputStream in = new BufferedInputStream(urlConnection.getInputStream(), - IO_BUFFER_SIZE_BYTES); - out = new BufferedOutputStream(new FileOutputStream(tempFile), IO_BUFFER_SIZE_BYTES); - - int oneByte; - while ((oneByte = in.read()) != -1) { - out.write(oneByte); - } - return tempFile; - } catch (final IOException ignored) { - } finally { - if (urlConnection != null) { - urlConnection.disconnect(); - } - if (out != null) { - try { - out.close(); - } catch (final IOException ignored) { - } - } - } - return null; - } - - /** - * Decode and sample down a {@link Bitmap} from a file to the requested - * width and height. - * - * @param filename The full path of the file to decode - * @param reqWidth The requested width of the resulting bitmap - * @param reqHeight The requested height of the resulting bitmap - * @return A {@link Bitmap} sampled down from the original with the same - * aspect ratio and dimensions that are equal to or greater than the - * requested width and height - */ - public static Bitmap decodeSampledBitmapFromFile(final String filename) { - - // First decode with inJustDecodeBounds=true to check dimensions - final BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - BitmapFactory.decodeFile(filename, options); - - // Calculate inSampleSize - options.inSampleSize = calculateInSampleSize(options, DEFAULT_MAX_IMAGE_WIDTH, - DEFAULT_MAX_IMAGE_HEIGHT); - - // Decode bitmap with inSampleSize set - options.inJustDecodeBounds = false; - return BitmapFactory.decodeFile(filename, options); - } - - /** - * Calculate an inSampleSize for use in a - * {@link android.graphics.BitmapFactory.Options} object when decoding - * bitmaps using the decode* methods from {@link BitmapFactory}. This - * implementation calculates the closest inSampleSize that will result in - * the final decoded bitmap having a width and height equal to or larger - * than the requested width and height. This implementation does not ensure - * a power of 2 is returned for inSampleSize which can be faster when - * decoding but results in a larger bitmap which isn't as useful for caching - * purposes. - * - * @param options An options object with out* params already populated (run - * through a decode* method with inJustDecodeBounds==true - * @param reqWidth The requested width of the resulting bitmap - * @param reqHeight The requested height of the resulting bitmap - * @return The value to be used for inSampleSize - */ - public static final int calculateInSampleSize(final BitmapFactory.Options options, - final int reqWidth, final int reqHeight) { - /* Raw height and width of image */ - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > reqHeight || width > reqWidth) { - if (width > height) { - inSampleSize = Math.round((float)height / (float)reqHeight); - } else { - inSampleSize = Math.round((float)width / (float)reqWidth); - } - - // This offers some additional logic in case the image has a strange - // aspect ratio. For example, a panorama may have a much larger - // width than height. In these cases the total pixels might still - // end up being too large to fit comfortably in memory, so we should - // be more aggressive with sample down the image (=larger - // inSampleSize). - - final float totalPixels = width * height; - - /* More than 2x the requested pixels we'll sample down further */ - final float totalReqPixelsCap = reqWidth * reqHeight * 2; - - while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) { - inSampleSize++; - } - } - return inSampleSize; - } - /** * Generates key used by album art cache. It needs both album name and artist name * to let to select correct image for the case when there are two albums with the * same artist. * - * @param albumName The album name the cache key needs to be generated. + * @param albumName The album name the cache key needs to be generated. * @param artistName The artist name the cache key needs to be generated. * @return */ diff --git a/src/com/cyngn/eleven/cache/ImageWorker.java b/src/com/cyngn/eleven/cache/ImageWorker.java index aa78a79..b31b2f1 100644 --- a/src/com/cyngn/eleven/cache/ImageWorker.java +++ b/src/com/cyngn/eleven/cache/ImageWorker.java @@ -19,18 +19,16 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.TransitionDrawable; -import android.os.AsyncTask; -import android.support.v7.graphics.Palette; -import android.support.v7.graphics.PaletteItem; -import android.support.v8.renderscript.Allocation; -import android.support.v8.renderscript.Element; import android.support.v8.renderscript.RenderScript; -import android.support.v8.renderscript.ScriptIntrinsicBlur; +import android.view.View; import android.widget.ImageView; import com.cyngn.eleven.R; +import com.cyngn.eleven.provider.PlaylistArtworkStore; import com.cyngn.eleven.utils.ApolloUtils; +import com.cyngn.eleven.utils.ImageUtils; import com.cyngn.eleven.widgets.BlurScrimImage; +import com.cyngn.eleven.cache.PlaylistWorkerTask.PlaylistWorkerType; import java.lang.ref.WeakReference; import java.util.concurrent.RejectedExecutionException; @@ -46,7 +44,7 @@ public abstract class ImageWorker { /** * Render script */ - private static RenderScript sRenderScript = null; + public static RenderScript sRenderScript = null; /** * Default transition drawable fade time @@ -183,6 +181,10 @@ public abstract class ImageWorker { targetBitmap = mDefaultArtist; break; + case PLAYLIST: + targetBitmap = mDefaultPlaylist; + break; + case ALBUM: default: targetBitmap = mDefault; @@ -197,344 +199,38 @@ public abstract class ImageWorker { return bitmapDrawable; } - /** - * The actual {@link AsyncTask} that will process the image. - */ - private class BitmapWorkerTask extends AsyncTask { - - /** - * The {@link ImageView} used to set the result - */ - private final WeakReference mImageReference; - - /** - * Type of URL to download - */ - private final ImageType mImageType; - - /** - * The key used to store cached entries - */ - private String mKey; - - /** - * Artist name param - */ - private String mArtistName; - - /** - * Album name parm - */ - private String mAlbumName; - - /** - * The album ID used to find the corresponding artwork - */ - private long mAlbumId; - - /** - * The URL of an image to download - */ - private String mUrl; - - /** - * Layer drawable used to cross fade the result from the worker - */ - protected Drawable mFromDrawable; - - /** - * Constructor of BitmapWorkerTask - * - * @param imageView The {@link ImageView} to use. - * @param imageType The type of image URL to fetch for. - */ - @SuppressWarnings("deprecation") - public BitmapWorkerTask(final ImageView imageView, final ImageType imageType) { - mImageReference = new WeakReference(imageView); - mImageType = imageType; - - // A transparent image (layer 0) and the new result (layer 1) - mFromDrawable = mTransparentDrawable; - } - - protected Bitmap getBitmapInBackground(final String... params) { - // Define the key - mKey = params[0]; - - // The result - Bitmap bitmap = null; - - // First, check the disk cache for the image - if (mKey != null && mImageCache != null && !isCancelled() - && getAttachedImageView() != null) { - bitmap = mImageCache.getCachedBitmap(mKey); - } - - // Define the album id now - mAlbumId = Long.valueOf(params[3]); - - // Second, if we're fetching artwork, check the device for the image - if (bitmap == null && mImageType.equals(ImageType.ALBUM) && mAlbumId >= 0 - && mKey != null && !isCancelled() && getAttachedImageView() != null - && mImageCache != null) { - bitmap = mImageCache.getCachedArtwork(mContext, mKey, mAlbumId); - } - - // Third, by now we need to download the image - if (bitmap == null && ApolloUtils.isOnline(mContext) && !isCancelled() - && getAttachedImageView() != null) { - // Now define what the artist name, album name, and url are. - mArtistName = params[1]; - mAlbumName = params[2]; - mUrl = processImageUrl(mArtistName, mAlbumName, mImageType); - if (mUrl != null) { - bitmap = processBitmap(mUrl); - } - } - - // Fourth, add the new image to the cache - if (bitmap != null && mKey != null && mImageCache != null) { - addBitmapToCache(mKey, bitmap); - } - - return bitmap; - } - - /** - * {@inheritDoc} - */ - @Override - protected Object doInBackground(final String... params) { - final Bitmap bitmap = getBitmapInBackground(params); - return createImageTransitionDrawable(mResources, mFromDrawable, bitmap, - FADE_IN_TIME, false, false); - } - - /** - * {@inheritDoc} - */ - @Override - protected void onPostExecute(Object result) { - if (isCancelled()) { - return; - } - - TransitionDrawable transitionDrawable = (TransitionDrawable)result; - - final ImageView imageView = getAttachedImageView(); - if (transitionDrawable != null && imageView != null) { - imageView.setImageDrawable(transitionDrawable); - } - } - - /** - * @return The {@link ImageView} associated with this task as long as - * the ImageView's task still points to this task as well. - * Returns null otherwise. - */ - protected ImageView getAttachedImageView() { - final ImageView imageView = mImageReference.get(); - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - if (this == bitmapWorkerTask) { - return imageView; - } - return null; - } - } - - /** - * This will download the image (if needed) and create a blur and set the scrim as well on the - * BlurScrimImage - */ - private class BlurBitmapWorkerTask extends BitmapWorkerTask { - // if the image is too small, the blur will look bad post scale up so we use the min size - // to scale up before bluring - private static final int MIN_BITMAP_SIZE = 500; - // number of times to run the blur - private static final int NUM_BLUR_RUNS = 8; - // 25f is the max blur radius possible - private static final float BLUR_RADIUS = 25f; - - // container for the result - private class ResultContainer { - public TransitionDrawable mImageViewBitmapDrawable; - public int mPaletteColor; - } - - /** - * The {@link BlurScrimImage} used to set the result - */ - private final WeakReference mBlurScrimImage; + public static Bitmap getBitmapInBackground(final Context context, final ImageCache imageCache, + final String key, final String albumName, final String artistName, + final long albumId, final ImageType imageType) { + // The result + Bitmap bitmap = null; - /** - * Constructor of BitmapWorkerTask - * - * @param blurScrimImage The {@link BlurScrimImage} to use. - * @param imageType The type of image URL to fetch for. - */ - @SuppressWarnings("deprecation") - public BlurBitmapWorkerTask(final BlurScrimImage blurScrimImage, final ImageType imageType) { - super(blurScrimImage.getImageView(), imageType); - mBlurScrimImage = new WeakReference(blurScrimImage); - - // use the existing image as the drawable and if it doesn't exist fallback to transparent - mFromDrawable = blurScrimImage.getImageView().getDrawable(); - if (mFromDrawable == null) { - mFromDrawable = mTransparentDrawable; - } + // First, check the disk cache for the image + if (key != null && imageCache != null) { + bitmap = imageCache.getCachedBitmap(key); } - /** - * {@inheritDoc} - */ - @Override - protected Object doInBackground(final String... params) { - Bitmap bitmap = getBitmapInBackground(params); - - ResultContainer result = new ResultContainer(); - - Bitmap output = null; - - if (bitmap != null) { - // now create the blur bitmap - Bitmap input = bitmap; - - // if the image is too small, scale it up before running through the blur - if (input.getWidth() < MIN_BITMAP_SIZE || input.getHeight() < MIN_BITMAP_SIZE) { - float multiplier = Math.max(MIN_BITMAP_SIZE / (float)input.getWidth(), - MIN_BITMAP_SIZE / (float)input.getHeight()); - input = input.createScaledBitmap(bitmap, (int)(input.getWidth() * multiplier), - (int)(input.getHeight() * multiplier), true); - // since we created a new bitmap, we can re-use the bitmap for our output - output = input; - } else { - // if we aren't creating a new bitmap, create a new output bitmap - output = Bitmap.createBitmap(input.getWidth(), input.getHeight(), input.getConfig()); - } - - // run the blur multiple times - for (int i = 0; i < NUM_BLUR_RUNS; i++) { - final Allocation inputAlloc = Allocation.createFromBitmap(sRenderScript, input); - final Allocation outputAlloc = Allocation.createTyped(sRenderScript, - inputAlloc.getType()); - final ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(sRenderScript, - Element.U8_4(sRenderScript)); - - script.setRadius(BLUR_RADIUS); - script.setInput(inputAlloc); - script.forEach(outputAlloc); - outputAlloc.copyTo(output); - - // if we run more than 1 blur, the new input should be the old output - input = output; - } - - // calculate the palette color - result.mPaletteColor = getPaletteColorInBackground(output); - - // create the bitmap transition drawable - result.mImageViewBitmapDrawable = createImageTransitionDrawable(mResources, mFromDrawable, - output, FADE_IN_TIME_SLOW, true, true); - - return result; - } - - return null; + // Second, if we're fetching artwork, check the device for the image + if (bitmap == null && imageType.equals(ImageType.ALBUM) && albumId >= 0 + && key != null && imageCache != null) { + bitmap = imageCache.getCachedArtwork(context, key, albumId); } - /** - * This will get the most vibrant palette color for a bitmap - * @param input to process - * @return the most vibrant color or transparent if none found - */ - private int getPaletteColorInBackground(Bitmap input) { - int color = Color.TRANSPARENT; - - if (input != null) { - Palette palette = Palette.generate(input); - PaletteItem paletteItem = palette.getVibrantColor(); - - // keep walking through the palette items to find a color if we don't have any - if (paletteItem == null) { - paletteItem = palette.getVibrantColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getLightVibrantColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getLightMutedColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getLightMutedColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getDarkVibrantColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getMutedColor(); - } - - if (paletteItem == null) { - paletteItem = palette.getDarkMutedColor(); - } - - if (paletteItem != null) { - // grab the rgb values - color = paletteItem.getRgb() | 0xFFFFFF; - - // make it 20% opacity - color &= 0x33000000; - } + // Third, by now we need to download the image + if (bitmap == null && ApolloUtils.isOnline(context)) { + // Now define what the artist name, album name, and url are. + String url = ImageUtils.processImageUrl(context, artistName, albumName, imageType); + if (url != null) { + bitmap = ImageUtils.processBitmap(context, url); } - - return color; } - /** - * {@inheritDoc} - */ - @Override - protected void onPostExecute(Object result) { - if (isCancelled()) { - return; - } - - BlurScrimImage blurScrimImage = mBlurScrimImage.get(); - if (blurScrimImage != null) { - if (result == null) { - // if we have no image, then signal the transition to the default state - blurScrimImage.transitionToDefaultState(); - } else { - ResultContainer resultContainer = (ResultContainer)result; - - // create the palette transition - TransitionDrawable paletteTransition = createPaletteTransition(blurScrimImage, - resultContainer.mPaletteColor); - - // set the transition drawable - blurScrimImage.setTransitionDrawable(false, - resultContainer.mImageViewBitmapDrawable, paletteTransition); - } - } + // Fourth, add the new image to the cache + if (bitmap != null && key != null && imageCache != null) { + imageCache.addBitmapToCache(key, bitmap); } - /** - * {@inheritDoc} - */ - @Override - protected final ImageView getAttachedImageView() { - final BlurScrimImage blurImage = mBlurScrimImage.get(); - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(blurImage); - if (this == bitmapWorkerTask) { - return blurImage.getImageView(); - } - return null; - } + return bitmap; } /** @@ -601,25 +297,21 @@ public abstract class ImageWorker { } /** - * Calls {@code cancel()} in the worker task - * - * @param imageView the {@link ImageView} to use + * Cancels and clears out any pending bitmap worker tasks on this image view + * @param image ImageView to check */ - public static final void cancelWork(final ImageView imageView) { - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - if (bitmapWorkerTask != null) { - bitmapWorkerTask.cancel(true); - } - } + public static final void cancelWork(final ImageView image) { + Object tag = image.getTag(); + if (tag != null && tag instanceof AsyncTaskContainer) { + AsyncTaskContainer asyncTaskContainer = (AsyncTaskContainer)tag; + BitmapWorkerTask bitmapWorkerTask = asyncTaskContainer.getBitmapWorkerTask(); + if (bitmapWorkerTask != null) { + bitmapWorkerTask.cancel(false); + } - /** - * Returns true if the current work has been canceled or if there was no - * work in progress on this image view. Returns false if the work in - * progress deals with the same data. The work is not stopped in that case. - */ - public static final boolean executePotentialWork(final Object data, final ImageView imageView) { - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - return executePotentialWork(data, bitmapWorkerTask); + // clear out the tag + image.setTag(null); + } } /** @@ -627,8 +319,8 @@ public abstract class ImageWorker { * work in progress on this image view. Returns false if the work in * progress deals with the same data. The work is not stopped in that case. */ - public static final boolean executePotentialWork(final Object data, final BlurScrimImage image) { - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(image); + public static final boolean executePotentialWork(final Object data, final View view) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(view); return executePotentialWork(data, bitmapWorkerTask); } @@ -655,34 +347,16 @@ public abstract class ImageWorker { * Used to determine if the current image drawable has an instance of * {@link BitmapWorkerTask} * - * @param imageView Any {@link ImageView}. - * @return Retrieve the currently active work task (if any) associated with - * this {@link ImageView}. null if there is no such task. - */ - private static final BitmapWorkerTask getBitmapWorkerTask(final ImageView imageView) { - if (imageView != null) { - final Drawable drawable = imageView.getDrawable(); - if (drawable instanceof AsyncDrawable) { - final AsyncDrawable asyncDrawable = (AsyncDrawable)drawable; - return asyncDrawable.getBitmapWorkerTask(); - } - } - return null; - } - - /** - * Used to determine if the current image drawable has an instance of - * {@link BitmapWorkerTask} - * - * @param image Any {@link BlurScrimImage}. + * @param view Any {@link View} that either is or contains an ImageView. * @return Retrieve the currently active work task (if any) associated with - * this {@link BlurScrimImage}. null if there is no such task. - */ - private static final BitmapWorkerTask getBitmapWorkerTask(final BlurScrimImage image) { - if (image != null) { - final AsyncDrawable asyncDrawable = (AsyncDrawable)image.getTag(); - if (asyncDrawable != null) { - return asyncDrawable.getBitmapWorkerTask(); + * this {@link View}. null if there is no such task. + */ + public static final BitmapWorkerTask getBitmapWorkerTask(final View view) { + if (view != null) { + final Object tag = view.getTag(); + if (tag instanceof AsyncTaskContainer) { + final AsyncTaskContainer asyncTaskContainer = (AsyncTaskContainer)tag; + return asyncTaskContainer.getBitmapWorkerTask(); } } return null; @@ -690,21 +364,19 @@ public abstract class ImageWorker { /** * A custom {@link BitmapDrawable} that will be attached to the - * {@link ImageView} while the work is in progress. Contains a reference to - * the actual worker task, so that it can be stopped if a new binding is + * {@link View} which either is or contains an {@link ImageView} while the work is in progress. + * Contains a reference to the actual worker task, so that it can be stopped if a new binding is * required, and makes sure that only the last started worker process can * bind its result, independently of the finish order. */ - private static final class AsyncDrawable extends ColorDrawable { + public static final class AsyncTaskContainer { private final WeakReference mBitmapWorkerTaskReference; /** * Constructor of AsyncDrawable */ - public AsyncDrawable(final Resources res, final Bitmap bitmap, - final BitmapWorkerTask mBitmapWorkerTask) { - super(Color.TRANSPARENT); + public AsyncTaskContainer(final BitmapWorkerTask mBitmapWorkerTask) { mBitmapWorkerTaskReference = new WeakReference(mBitmapWorkerTask); } @@ -717,7 +389,7 @@ public abstract class ImageWorker { } /** - * Called to fetch the artist or ablum art. + * Called to fetch the artist or album art. * * @param key The unique identifier for the image. * @param artistName The artist name for the Last.fm API. @@ -748,30 +420,78 @@ public abstract class ImageWorker { if (executePotentialWork(key, imageView) && imageView != null && !mImageCache.isDiskCachePaused()) { // cancel the old task if any - final Drawable previousDrawable = imageView.getDrawable(); - if (previousDrawable != null && previousDrawable instanceof AsyncDrawable) { - BitmapWorkerTask workerTask = ((AsyncDrawable)previousDrawable).getBitmapWorkerTask(); - if (workerTask != null) { - workerTask.cancel(false); - } - } + cancelWork(imageView); // Otherwise run the worker task - final BitmapWorkerTask bitmapWorkerTask = new BitmapWorkerTask(imageView, imageType); - final AsyncDrawable asyncDrawable = new AsyncDrawable(mResources, mDefault, - bitmapWorkerTask); - imageView.setImageDrawable(asyncDrawable); + final SimpleBitmapWorkerTask bitmapWorkerTask = new SimpleBitmapWorkerTask(key, + imageView, imageType, mTransparentDrawable, mContext); + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(bitmapWorkerTask); + imageView.setTag(asyncTaskContainer); try { - ApolloUtils.execute(false, bitmapWorkerTask, key, + ApolloUtils.execute(false, bitmapWorkerTask, artistName, albumName, String.valueOf(albumId)); } catch (RejectedExecutionException e) { - // Executor has exhausted queue space, show default artwork - imageView.setImageBitmap(getDefaultArtwork()); + // Executor has exhausted queue space } } } } + /** + * Called to fetch a playlist's top artist or cover art + * @param playlistId playlist identifier + * @param type of work to get (Artist or CoverArt) + * @param imageView to set the image to + */ + public void loadPlaylistImage(final long playlistId, final PlaylistWorkerType type, + final ImageView imageView) { + if (mImageCache == null || imageView == null) { + return; + } + + String key = null; + switch (type) { + case Artist: + key = PlaylistArtworkStore.getArtistCacheKey(playlistId); + break; + case CoverArt: + key = PlaylistArtworkStore.getCoverCacheKey(playlistId); + break; + } + + // First, check the memory for the image + final Bitmap lruBitmap = mImageCache.getBitmapFromMemCache(key); + if (lruBitmap != null) { + // Bitmap found in memory cache + imageView.setImageBitmap(lruBitmap); + } else { + // if a background drawable hasn't been set, create one so that even if + // the disk cache is paused we see something + if (imageView.getBackground() == null) { + imageView.setBackgroundDrawable(getNewDefaultBitmapDrawable(ImageType.PLAYLIST)); + } + } + + // even though we may have found the image in the cache, we want to check if the playlist + // has been updated, or it's been too long since the last update and change the image + // accordingly + if (executePotentialWork(key, imageView) && !mImageCache.isDiskCachePaused()) { + // cancel the old task if any + cancelWork(imageView); + + // Otherwise run the worker task + final PlaylistWorkerTask bitmapWorkerTask = new PlaylistWorkerTask(key, playlistId, type, + lruBitmap != null, imageView, mTransparentDrawable, mContext); + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(bitmapWorkerTask); + imageView.setTag(asyncTaskContainer); + try { + ApolloUtils.execute(false, bitmapWorkerTask); + } catch (RejectedExecutionException e) { + // Executor has exhausted queue space + } + } + } + /** * Called to fetch the blurred artist or album art. * @@ -795,7 +515,7 @@ public abstract class ImageWorker { if (executePotentialWork(blurKey, blurScrimImage) && blurScrimImage != null && !mImageCache.isDiskCachePaused()) { // cancel the old task if any - final AsyncDrawable previousDrawable = (AsyncDrawable)blurScrimImage.getTag(); + final AsyncTaskContainer previousDrawable = (AsyncTaskContainer)blurScrimImage.getTag(); if (previousDrawable != null) { BitmapWorkerTask workerTask = previousDrawable.getBitmapWorkerTask(); if (workerTask != null) { @@ -804,14 +524,13 @@ public abstract class ImageWorker { } // Otherwise run the worker task - final BlurBitmapWorkerTask blurWorkerTask = new BlurBitmapWorkerTask(blurScrimImage, imageType); - final AsyncDrawable asyncDrawable = new AsyncDrawable(mResources, mDefault, - blurWorkerTask); - blurScrimImage.setTag(asyncDrawable); + final BlurBitmapWorkerTask blurWorkerTask = new BlurBitmapWorkerTask(key, blurScrimImage, + imageType, mTransparentDrawable, mContext, sRenderScript); + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(blurWorkerTask); + blurScrimImage.setTag(asyncTaskContainer); try { - ApolloUtils.execute(false, blurWorkerTask, key, - artistName, albumName, String.valueOf(albumId)); + ApolloUtils.execute(false, blurWorkerTask, artistName, albumName, String.valueOf(albumId)); } catch (RejectedExecutionException e) { // Executor has exhausted queue space, show default artwork blurScrimImage.transitionToDefaultState(); @@ -819,34 +538,10 @@ public abstract class ImageWorker { } } - /** - * Subclasses should override this to define any processing or work that - * must happen to produce the final {@link Bitmap}. This will be executed in - * a background thread and be long running. - * - * @param key The key to identify which image to process, as provided by - * {@link ImageWorker#loadImage(mKey, ImageView)} - * @return The processed {@link Bitmap}. - */ - protected abstract Bitmap processBitmap(String key); - - /** - * Subclasses should override this to define any processing or work that - * must happen to produce the URL needed to fetch the final {@link Bitmap}. - * - * @param artistName The artist name param used in the Last.fm API. - * @param albumName The album name param used in the Last.fm API. - * @param imageType The type of image URL to fetch for. - * @return The image URL for an artist image or album image. - */ - protected abstract String processImageUrl(String artistName, String albumName, - ImageType imageType); - /** * Used to define what type of image URL to fetch for, artist or album. */ public enum ImageType { - ARTIST, ALBUM; + ARTIST, ALBUM, PLAYLIST; } - } diff --git a/src/com/cyngn/eleven/cache/PlaylistWorkerTask.java b/src/com/cyngn/eleven/cache/PlaylistWorkerTask.java new file mode 100644 index 0000000..880c450 --- /dev/null +++ b/src/com/cyngn/eleven/cache/PlaylistWorkerTask.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.provider.MediaStore; +import android.widget.ImageView; + +import com.cyngn.eleven.cache.ImageWorker.ImageType; +import com.cyngn.eleven.loaders.PlaylistSongLoader; +import com.cyngn.eleven.loaders.SortedCursor; +import com.cyngn.eleven.provider.PlaylistArtworkStore; +import com.cyngn.eleven.provider.SongPlayCount; + +import java.util.ArrayList; +import java.util.HashSet; + +/** + * The playlistWorkerTask will load either the top artist image or the cover art (a combination of + * up to 4 of the top song's album images) into the designated ImageView. If not enough time has + * elapsed since the last update or if the # of songs in the playlist hasn't changed, no new images + * will be loaded. + */ +public class PlaylistWorkerTask extends BitmapWorkerTask { + // the work type + public enum PlaylistWorkerType { + Artist, CoverArt + } + + // number of images to load in the cover art + private static final int MAX_NUM_BITMAPS_TO_LOAD = 4; + + protected final long mPlaylistId; + protected final PlaylistArtworkStore mPlaylistStore; + protected final PlaylistWorkerType mWorkerType; + + // if we've found it in the cache, don't do any more logic unless enough time has elapsed or + // if the playlist has changed + protected final boolean mFoundInCache; + + /** + * Constructor of PlaylistWorkerTask + * @param key the key of the image to store to + * @param playlistId the playlist identifier + * @param type Artist or CoverArt? + * @param foundInCache does this exist in the memory cache already + * @param imageView The {@link ImageView} to use. + * @param fromDrawable what drawable to transition from + */ + public PlaylistWorkerTask(final String key, final long playlistId, final PlaylistWorkerType type, + final boolean foundInCache, final ImageView imageView, + final Drawable fromDrawable, final Context context) { + super(key, imageView, ImageType.PLAYLIST, fromDrawable, context); + + mPlaylistId = playlistId; + mWorkerType = type; + mPlaylistStore = PlaylistArtworkStore.getInstance(mContext); + mFoundInCache = foundInCache; + } + + /** + * {@inheritDoc} + */ + @Override + protected TransitionDrawable doInBackground(final Void... params) { + if (isCancelled()) { + return null; + } + + Bitmap bitmap = null; + + // See if we need to update the image + boolean needsUpdate = false; + if (mWorkerType == PlaylistWorkerType.Artist + && mPlaylistStore.needsArtistArtUpdate(mPlaylistId)) { + needsUpdate = true; + } else if (mWorkerType == PlaylistWorkerType.CoverArt + && mPlaylistStore.needsCoverArtUpdate(mPlaylistId)) { + needsUpdate = true; + } + + // if we don't need to update and we've already found it in the cache, then return + if (!needsUpdate && mFoundInCache) { + return null; + } + + // if we didn't find it in memory cache, try the disk cache + if (!mFoundInCache) { + bitmap = mImageCache.getCachedBitmap(mKey); + } + + // if we found a bitmap and we don't need an update, return it + if (bitmap != null && !needsUpdate) { + return createImageTransitionDrawable(bitmap); + } + + // otherwise re-run the logic to get the bitmap + Cursor sortedCursor = null; + + try { + // get the top songs for our playlist + sortedCursor = getTopSongsForPlaylist(); + + if (sortedCursor == null || sortedCursor.getCount() == 0 || isCancelled()) { + return null; + } + + // run the appropriate logic + if (mWorkerType == PlaylistWorkerType.Artist) { + bitmap = loadTopArtist(sortedCursor); + } else { + bitmap = loadTopSongs(sortedCursor); + } + } finally { + if (sortedCursor != null) { + sortedCursor.close(); + } + } + + // if we have a bitmap create a transition drawable + if (bitmap != null) { + return createImageTransitionDrawable(bitmap); + } + + return null; + } + + /** + * This gets the sorted cursor of the songs from a playlist based on play count + * @return Cursor containing the sorted list + */ + protected Cursor getTopSongsForPlaylist() { + Cursor playlistCursor = null; + SortedCursor sortedCursor = null; + + try { + // gets the songs in the playlist + playlistCursor = PlaylistSongLoader.makePlaylistSongCursor(mContext, mPlaylistId); + if (playlistCursor == null || !playlistCursor.moveToFirst()) { + return null; + } + + // get all the ids in the list + long[] songIds = new long[playlistCursor.getCount()]; + do { + long id = playlistCursor.getLong(playlistCursor.getColumnIndex( + MediaStore.Audio.Playlists.Members.AUDIO_ID)); + + songIds[playlistCursor.getPosition()] = id; + } while (playlistCursor.moveToNext()); + + if (isCancelled()) { + return null; + } + + // find the sorted order for the playlist based on the top songs database + long[] order = SongPlayCount.getInstance(mContext).getTopPlayedResultsForList(songIds); + + // create a new cursor that takes the playlist cursor and the sorted order + sortedCursor = new SortedCursor(playlistCursor, order, + MediaStore.Audio.Playlists.Members.AUDIO_ID); + + // since this cursor is now wrapped by SortedTracksCursor, remove the reference here + // so we don't accidentally close it in the finally loop + playlistCursor = null; + } finally { + // if we quit early from isCancelled(), close our cursor + if (playlistCursor != null) { + playlistCursor.close(); + playlistCursor = null; + } + } + + return sortedCursor; + } + + /** + * Gets the most played song's artist image + * @param sortedCursor the sorted playlist song cursor + * @return Bitmap of the artist + */ + protected Bitmap loadTopArtist(Cursor sortedCursor) { + if (sortedCursor == null || !sortedCursor.moveToFirst()) { + return null; + } + + Bitmap bitmap = null; + int artistIndex = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ARTIST); + String artistName = null; + + do { + if (isCancelled()) { + return null; + } + + artistName = sortedCursor.getString(artistIndex); + // try to load the bitmap + bitmap = ImageWorker.getBitmapInBackground(mContext, mImageCache, artistName, + null, artistName, -1, ImageType.ARTIST); + } while (sortedCursor.moveToNext() && bitmap == null); + + sortedCursor.close(); + + if (bitmap != null) { + // add the image to the cache + mImageCache.addBitmapToCache(mKey, bitmap, true); + + // store this artist name into the db + mPlaylistStore.updateArtistArt(mPlaylistId); + } + + return bitmap; + } + + /** + * Gets the Cover Art of the playlist, which is a combination of the top song's album image + * @param sortedCursor the sorted playlist song cursor + * @return Bitmap of the artist + */ + protected Bitmap loadTopSongs(Cursor sortedCursor) { + if (sortedCursor == null || !sortedCursor.moveToFirst()) { + return null; + } + + ArrayList loadedBitmaps = new ArrayList(MAX_NUM_BITMAPS_TO_LOAD); + + final int artistIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ARTIST); + final int albumIdIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ALBUM_ID); + final int albumIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ALBUM); + + Bitmap bitmap = null; + String artistName = null; + String albumName = null; + long albumId = -1; + + // create a hashset of the keys so we don't load images from the same album multiple times + HashSet keys = new HashSet(sortedCursor.getCount()); + + do { + if (isCancelled()) { + return null; + } + + artistName = sortedCursor.getString(artistIdx); + albumName = sortedCursor.getString(albumIdx); + albumId = sortedCursor.getLong(albumIdIdx); + + String key = ImageFetcher.generateAlbumCacheKey(albumName, artistName); + + // if we successfully added the key (ie the key didn't previously exist) + if (keys.add(key)) { + // try to load the bitmap + bitmap = ImageWorker.getBitmapInBackground(mContext, mImageCache, + key, albumName, artistName, albumId, ImageType.ALBUM); + + // if we got the bitmap, add it to the list + if (bitmap != null) { + loadedBitmaps.add(bitmap); + bitmap = null; + } + } + } while (sortedCursor.moveToNext() && loadedBitmaps.size() < MAX_NUM_BITMAPS_TO_LOAD); + + sortedCursor.close(); + + // if we found at least 1 bitmap + if (loadedBitmaps.size() > 0) { + // get the first bitmap + bitmap = loadedBitmaps.get(0); + + // if we have many bitmaps + if (loadedBitmaps.size() == MAX_NUM_BITMAPS_TO_LOAD) { + // create a combined bitmap of the 4 images + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + Bitmap combinedBitmap = Bitmap.createBitmap(width, height, + bitmap.getConfig()); + Canvas combinedCanvas = new Canvas(combinedBitmap); + + // top left + combinedCanvas.drawBitmap(loadedBitmaps.get(0), null, + new Rect(0, 0, width / 2, height / 2), null); + + // top right + combinedCanvas.drawBitmap(loadedBitmaps.get(1), null, + new Rect(width / 2, 0, width, height / 2), null); + + // bottom left + combinedCanvas.drawBitmap(loadedBitmaps.get(2), null, + new Rect(0, height / 2, width / 2, height), null); + + // bottom right + combinedCanvas.drawBitmap(loadedBitmaps.get(3), null, + new Rect(width / 2, height / 2, width, height), null); + + combinedCanvas = null; + bitmap = combinedBitmap; + } + + if (bitmap != null) { + // add the image to the cache + mImageCache.addBitmapToCache(mKey, bitmap, true); + + // store this artist name into the db + mPlaylistStore.updateCoverArt(mPlaylistId); + } + + return bitmap; + } + + return null; + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(TransitionDrawable transitionDrawable) { + final ImageView imageView = getAttachedImageView(); + if (transitionDrawable != null && imageView != null) { + imageView.setImageDrawable(transitionDrawable); + } + } +} diff --git a/src/com/cyngn/eleven/cache/SimpleBitmapWorkerTask.java b/src/com/cyngn/eleven/cache/SimpleBitmapWorkerTask.java new file mode 100644 index 0000000..0e1c3bd --- /dev/null +++ b/src/com/cyngn/eleven/cache/SimpleBitmapWorkerTask.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.widget.ImageView; +import com.cyngn.eleven.cache.ImageWorker.ImageType; + +/** + * The actual {@link android.os.AsyncTask} that will process the image. + */ +public class SimpleBitmapWorkerTask extends BitmapWorkerTask { + + /** + * Constructor of BitmapWorkerTask + * + * @param key the key of the image to store to + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public SimpleBitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context) { + super(key, imageView, imageType, fromDrawable, context); + } + + /** + * {@inheritDoc} + */ + @Override + protected TransitionDrawable doInBackground(final String... params) { + if (isCancelled()) { + return null; + } + + final Bitmap bitmap = getBitmapInBackground(params); + return createImageTransitionDrawable(bitmap); + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(TransitionDrawable transitionDrawable) { + final ImageView imageView = getAttachedImageView(); + if (transitionDrawable != null && imageView != null) { + imageView.setImageDrawable(transitionDrawable); + } + } +} diff --git a/src/com/cyngn/eleven/loaders/SortedCursor.java b/src/com/cyngn/eleven/loaders/SortedCursor.java new file mode 100644 index 0000000..ffd43b4 --- /dev/null +++ b/src/com/cyngn/eleven/loaders/SortedCursor.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.loaders; + +import android.database.AbstractCursor; +import android.database.Cursor; + +import java.util.ArrayList; +import java.util.HashMap; + +/** + * This cursor basically wraps a song cursor and is given a list of the order of the ids of the + * contents of the cursor. It wraps the Cursor and simulates the internal cursor being sorted + * by moving the point to the appropriate spot + */ +public class SortedCursor extends AbstractCursor { + // cursor to wrap + private final Cursor mCursor; + // the map of external indices to internal indices + private ArrayList mOrderedPositions; + + /** + * @param cursor to wrap + * @param order the list of ids in sorted order to display + * @param columnName the column name of the id to look up in the internal cursor + */ + public SortedCursor(final Cursor cursor, final long[] order, final String columnName) { + if (cursor == null) { + throw new IllegalArgumentException("Non-null cursor is needed"); + } + + mCursor = cursor; + buildCursorPositionMapping(order, columnName); + } + + /** + * This function populates mOrderedPositions with the cursor positions in the order based + * on the order passed in + * @param order the target order of the internal cursor + */ + private void buildCursorPositionMapping(final long[] order, final String columnName) { + mOrderedPositions = new ArrayList(mCursor.getCount()); + + HashMap mapCursorPositions = new HashMap(mCursor.getCount()); + final int idPosition = mCursor.getColumnIndex(columnName); + + if (mCursor.moveToFirst()) { + // first figure out where each of the ids are in the cursor + do { + mapCursorPositions.put(mCursor.getLong(idPosition), mCursor.getPosition()); + } while (mCursor.moveToNext()); + + // now create the ordered positions to map to the internal cursor given the + // external sort order + for (long id : order) { + if (mapCursorPositions.containsKey(id)) { + mOrderedPositions.add(mapCursorPositions.get(id)); + } + } + } + } + + @Override + public void close() { + mCursor.close(); + + super.close(); + } + + @Override + public int getCount() { + return mOrderedPositions.size(); + } + + @Override + public String[] getColumnNames() { + return mCursor.getColumnNames(); + } + + @Override + public String getString(int column) { + return mCursor.getString(column); + } + + @Override + public short getShort(int column) { + return mCursor.getShort(column); + } + + @Override + public int getInt(int column) { + return mCursor.getInt(column); + } + + @Override + public long getLong(int column) { + return mCursor.getLong(column); + } + + @Override + public float getFloat(int column) { + return mCursor.getFloat(column); + } + + @Override + public double getDouble(int column) { + return mCursor.getDouble(column); + } + + @Override + public boolean isNull(int column) { + return mCursor.isNull(column); + } + + @Override + public boolean onMove(int oldPosition, int newPosition) { + if (newPosition >= 0 && newPosition < getCount()) { + mCursor.moveToPosition(mOrderedPositions.get(newPosition)); + return true; + } + + return false; + } +} diff --git a/src/com/cyngn/eleven/loaders/TopTracksCursor.java b/src/com/cyngn/eleven/loaders/TopTracksCursor.java deleted file mode 100644 index 70e3054..0000000 --- a/src/com/cyngn/eleven/loaders/TopTracksCursor.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (C) 2014 Cyanogen, Inc. - */ -package com.cyngn.eleven.loaders; - -import android.database.AbstractCursor; -import android.database.Cursor; -import android.provider.BaseColumns; - -import java.util.ArrayList; -import java.util.HashMap; - -/** - * This cursor basically wraps a song cursor and is given a list of the order of the ids of the - * contents of the cursor. It wraps the Cursor and simulates the internal cursor being sorted - * by moving the point to the appropriate spot - */ -public class TopTracksCursor extends AbstractCursor { - // cursor to wrap - private final Cursor mCursor; - // the map of external indices to internal indices - private ArrayList mOrderedPositions; - - /** - * @param cursor to wrap - * @param order the list of ids in sorted order to display - */ - public TopTracksCursor(final Cursor cursor, final long[] order) { - if (cursor == null) { - throw new IllegalArgumentException("Non-null cursor is needed"); - } - - mCursor = cursor; - buildCursorPositionMapping(order); - } - - /** - * This function populates mOrderedPositions with the cursor positions in the order based - * on the order passed in - * @param order the target order of the internal cursor - */ - private void buildCursorPositionMapping(final long[] order) { - mOrderedPositions = new ArrayList(mCursor.getCount()); - - HashMap mapCursorPositions = new HashMap(mCursor.getCount()); - final int idPosition = mCursor.getColumnIndex(BaseColumns._ID); - - if (mCursor.moveToFirst()) { - // first figure out where each of the ids are in the cursor - do { - mapCursorPositions.put(mCursor.getLong(idPosition), mCursor.getPosition()); - } while (mCursor.moveToNext()); - - // now create the ordered positions to map to the internal cursor given the - // external sort order - for (long id : order) { - if (mapCursorPositions.containsKey(id)) { - mOrderedPositions.add(mapCursorPositions.get(id)); - } - } - } - } - - @Override - public int getCount() { - return mOrderedPositions.size(); - } - - @Override - public String[] getColumnNames() { - return mCursor.getColumnNames(); - } - - @Override - public String getString(int column) { - return mCursor.getString(column); - } - - @Override - public short getShort(int column) { - return mCursor.getShort(column); - } - - @Override - public int getInt(int column) { - return mCursor.getInt(column); - } - - @Override - public long getLong(int column) { - return mCursor.getLong(column); - } - - @Override - public float getFloat(int column) { - return mCursor.getFloat(column); - } - - @Override - public double getDouble(int column) { - return mCursor.getDouble(column); - } - - @Override - public boolean isNull(int column) { - return mCursor.isNull(column); - } - - @Override - public boolean onMove(int oldPosition, int newPosition) { - if (newPosition >= 0 && newPosition < getCount()) { - mCursor.moveToPosition(mOrderedPositions.get(newPosition)); - return true; - } - - return false; - } -} diff --git a/src/com/cyngn/eleven/loaders/TopTracksLoader.java b/src/com/cyngn/eleven/loaders/TopTracksLoader.java index 4e1940e..175eec0 100644 --- a/src/com/cyngn/eleven/loaders/TopTracksLoader.java +++ b/src/com/cyngn/eleven/loaders/TopTracksLoader.java @@ -54,11 +54,11 @@ public class TopTracksLoader extends SongLoader { selection.append(")"); - // get a list of songs with the data given the selection statemtn + // get a list of songs with the data given the selection statment Cursor songCursor = makeSongCursor(context, selection.toString()); if (songCursor != null) { // now return the wrapped TopTracksCursor to handle sorting given order - return new TopTracksCursor(songCursor, order); + return new SortedCursor(songCursor, order, BaseColumns._ID); } } diff --git a/src/com/cyngn/eleven/provider/PlaylistArtworkStore.java b/src/com/cyngn/eleven/provider/PlaylistArtworkStore.java new file mode 100644 index 0000000..2751a1e --- /dev/null +++ b/src/com/cyngn/eleven/provider/PlaylistArtworkStore.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.provider; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import com.cyngn.eleven.utils.MusicUtils; + +/** + * This db stores the details to generate the playlist artwork including when it was + * last updated and the # of songs in the playlist when it last updated + */ +public class PlaylistArtworkStore extends SQLiteOpenHelper { + /* Version constant to increment when the database should be rebuilt */ + private static final int VERSION = 1; + + private static final long ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; + + /* Name of database file */ + public static final String DATABASENAME = "playlistdetail.db"; + + private static PlaylistArtworkStore sInstance = null; + + /** + * @param context The {@link android.content.Context} to use + * @return A new instance of this class. + */ + public static final synchronized PlaylistArtworkStore getInstance(final Context context) { + if (sInstance == null) { + sInstance = new PlaylistArtworkStore(context.getApplicationContext()); + } + return sInstance; + } + + /** + * @param playlistId playlist identifier + * @return the key used for the imagae cache for the cover art + */ + public static final String getCoverCacheKey(final long playlistId) { + return "playlist_cover_" + playlistId; + } + + /** + * @param playlistId playlist identifier + * @return the key used for the imagae cache for the top artist image + */ + public static final String getArtistCacheKey(final long playlistId) { + return "playlist_artist_" + playlistId; + } + + private final Context mContext; + + /** + * Constructor of RecentStore + * + * @param context The {@link android.content.Context} to use + */ + public PlaylistArtworkStore(final Context context) { + super(context, DATABASENAME, null, VERSION); + + mContext = context; + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate(final SQLiteDatabase db) { + // create the table + StringBuilder builder = new StringBuilder(); + builder.append("CREATE TABLE IF NOT EXISTS "); + builder.append(PlaylistArtworkStoreColumns.NAME); + builder.append("("); + builder.append(PlaylistArtworkStoreColumns.ID); + builder.append(" INT UNIQUE,"); + + builder.append(PlaylistArtworkStoreColumns.LAST_UPDATE_ARTIST); + builder.append(" LONG DEFAULT 0,"); + + builder.append(PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_ARTIST); + builder.append(" INT DEFAULT 0,"); + + builder.append(PlaylistArtworkStoreColumns.LAST_UPDATE_COVER); + builder.append(" LONG DEFAULT 0,"); + + builder.append(PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_COVER); + builder.append(" INT DEFAULT 0);"); + + db.execSQL(builder.toString()); + } + + /** + * {@inheritDoc} + */ + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + // If we ever have upgrade, this code should be changed to handle this more gracefully + db.execSQL("DROP TABLE IF EXISTS " + PlaylistArtworkStoreColumns.NAME); + onCreate(db); + } + + /** + * @param playlistId playlist identifier + * @return true if the artist artwork should be updated based on time since last update and + * whether the # of songs for the playlist has changed + */ + public boolean needsArtistArtUpdate(final long playlistId) { + return needsUpdate(playlistId, PlaylistArtworkStoreColumns.LAST_UPDATE_ARTIST, + PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_ARTIST); + } + + /** + * @param playlistId playlist identifier + * @return true if the cover artwork should be updated based on time since last update and + * whether the # of songs for the playlist has changed + */ + public boolean needsCoverArtUpdate(final long playlistId) { + return needsUpdate(playlistId, PlaylistArtworkStoreColumns.LAST_UPDATE_COVER, + PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_COVER); + } + + /** + * Updates the time and the # of songs in the db for the artist section of the table + * @param playlistId playlist identifier + */ + public void updateArtistArt(final long playlistId) { + updateOrInsertTime(playlistId, PlaylistArtworkStoreColumns.LAST_UPDATE_ARTIST, + PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_ARTIST); + } + + /** + * Updates the time and the # of songs in the db for the cover art of the table + * @param playlistId playlist identifier + */ + public void updateCoverArt(final long playlistId) { + updateOrInsertTime(playlistId, PlaylistArtworkStoreColumns.LAST_UPDATE_COVER, + PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_COVER); + } + + /** + * Internal function to update the entry for the columns passed in + * @param playlistId playlist identifier + * @param columnName the column to update to the current time + * @param countColumnName the column to set the # of songs to based on the playlist + */ + private void updateOrInsertTime(final long playlistId, final String columnName, final String countColumnName) { + SQLiteDatabase database = getWritableDatabase(); + + database.beginTransaction(); + + // gets the existing values for the entry if it exists + ContentValues values = getExistingContentValues(playlistId); + boolean existingEntry = values.size() > 0; + // update the values + values.put(PlaylistArtworkStoreColumns.ID, playlistId); + values.put(columnName, System.currentTimeMillis()); + values.put(countColumnName, MusicUtils.getSongCountForPlaylist(mContext, playlistId)); + + // if it is an existing entry, update, otherwise insert + if (existingEntry) { + database.update(PlaylistArtworkStoreColumns.NAME, values, + PlaylistArtworkStoreColumns.ID + "=" + playlistId, null); + } else { + database.insert(PlaylistArtworkStoreColumns.NAME, null, values); + } + + database.setTransactionSuccessful(); + database.endTransaction(); + } + + /** + * Internal function to get the existing values for a playlist entry + * @param playlistId playlist identifier + * @return the content values + */ + private ContentValues getExistingContentValues(final long playlistId) { + ContentValues values = new ContentValues(4); + Cursor c = getEntry(playlistId); + if (c != null && c.moveToFirst()) { + values.put(PlaylistArtworkStoreColumns.ID, c.getLong(0)); + values.put(PlaylistArtworkStoreColumns.LAST_UPDATE_ARTIST, c.getLong(1)); + values.put(PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_ARTIST, c.getInt(2)); + values.put(PlaylistArtworkStoreColumns.LAST_UPDATE_COVER, c.getLong(3)); + values.put(PlaylistArtworkStoreColumns.NUM_SONGS_LAST_UPDATE_COVER, c.getInt(4)); + c.close(); + c = null; + } + + return values; + } + + /** + * Internal function to return whether the columns show that this needs an update + * @param playlistId playlist identifier + * @param columnName the column to inspect + * @param countColumnName the column count to inspect + * @return + */ + private boolean needsUpdate(final long playlistId, final String columnName, final String countColumnName) { + // get the entry + Cursor c = getEntry(playlistId); + + if (c != null && c.moveToFirst()) { + final long lastUpdate = c.getLong(c.getColumnIndex(columnName)); + final long msSinceEpoch = System.currentTimeMillis(); + final int songCount = MusicUtils.getSongCountForPlaylist(mContext, playlistId); + final int lastUpdatedSongCount = c.getInt(c.getColumnIndex(countColumnName)); + + c.close(); + c = null; + + // if the elapsed time since our last update is less than a day and the + // number of songs in the playlist hasn't changed, then don't update + if (msSinceEpoch - lastUpdate < ONE_DAY_IN_MS && + songCount == lastUpdatedSongCount) { + return false; + } + } + + return true; + } + + /** + * Internal function to get the cursor entry for the playlist + * @param playlistId playlist identifier + * @return cursor + */ + private Cursor getEntry(final long playlistId) { + SQLiteDatabase db = getReadableDatabase(); + return db.query(PlaylistArtworkStoreColumns.NAME, null, + PlaylistArtworkStoreColumns.ID + "=" + playlistId, null, null, null, null); + } + + public interface PlaylistArtworkStoreColumns { + /* Table name */ + public static final String NAME = "playlist_details"; + + /* Playlist ID column */ + public static final String ID = "playlistid"; + + /* When the top artist was last updated */ + public static final String LAST_UPDATE_ARTIST = "last_updated_artist"; + + /* The number of songs when we last updated the artist */ + public static final String NUM_SONGS_LAST_UPDATE_ARTIST = "num_songs_last_updated_artist"; + + /* When the cover art was last updated */ + public static final String LAST_UPDATE_COVER = "last_updated_cover"; + + /* The number of songs when we last updated the cover */ + public static final String NUM_SONGS_LAST_UPDATE_COVER = "num_songs_last_updated_cover"; + } +} diff --git a/src/com/cyngn/eleven/provider/SongPlayCount.java b/src/com/cyngn/eleven/provider/SongPlayCount.java index 0ec54dd..61012a6 100644 --- a/src/com/cyngn/eleven/provider/SongPlayCount.java +++ b/src/com/cyngn/eleven/provider/SongPlayCount.java @@ -12,6 +12,10 @@ import android.database.sqlite.SQLiteOpenHelper; import android.view.animation.AccelerateInterpolator; import android.view.animation.Interpolator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + /** * This database tracks the number of play counts for an individual song. This is used to drive * the top played tracks as well as the playlist images @@ -263,6 +267,85 @@ public class SongPlayCount extends SQLiteOpenHelper { (numResults <= 0 ? null : String.valueOf(numResults))); } + /** + * Given a list of ids, it sorts the results based on the most played results + * @param ids list + * @return sorted list - this may be smaller than the list passed in for performance reasons + */ + public long[] getTopPlayedResultsForList(long[] ids) { + final int MAX_NUMBER_SONGS_TO_ANALYZE = 250; + + if (ids == null || ids.length == 0) { + return null; + } + + HashSet uniqueIds = new HashSet(ids.length); + + // create the list of ids to select against + StringBuilder selection = new StringBuilder(); + selection.append(SongPlayCountColumns.ID); + selection.append(" IN ("); + + // add the first element to handle the separator case for the first element + uniqueIds.add(ids[0]); + selection.append(ids[0]); + + for (int i = 1; i < ids.length; i++) { + // if the new id doesn't exist + if (uniqueIds.add(ids[i])) { + // append a separator + selection.append(","); + + // append the id + selection.append(ids[i]); + + // for performance reasons, only look at a certain number of songs + // in case their playlist is ridiculously large + if (uniqueIds.size() >= MAX_NUMBER_SONGS_TO_ANALYZE) { + break; + } + } + } + + // close out the selection + selection.append(")"); + + long[] sortedList = new long[uniqueIds.size()]; + + // now query for the songs + final SQLiteDatabase database = getReadableDatabase(); + Cursor topSongsCursor = null; + int idx = 0; + + try { + topSongsCursor = database.query(SongPlayCountColumns.NAME, + new String[]{ SongPlayCountColumns.ID }, selection.toString(), null, null, + null, SongPlayCountColumns.PLAYCOUNTSCORE + " DESC"); + + if (topSongsCursor != null && topSongsCursor.moveToFirst()) { + do { + // for each id found, add it to the list and remove it from the unique ids + long id = topSongsCursor.getLong(0); + sortedList[idx++] = id; + uniqueIds.remove(id); + } while (topSongsCursor.moveToNext()); + } + } finally { + if (topSongsCursor != null) { + topSongsCursor.close(); + topSongsCursor = null; + } + } + + // append the remaining items - these are songs that haven't been played recently + Iterator iter = uniqueIds.iterator(); + while (iter.hasNext()) { + sortedList[idx++] = iter.next(); + } + + return sortedList; + } + /** * This updates all the results for the getTopPlayedResults so that we can get an * accurate list of the top played results diff --git a/src/com/cyngn/eleven/recycler/RecycleHolder.java b/src/com/cyngn/eleven/recycler/RecycleHolder.java index 07fdabf..5d86239 100644 --- a/src/com/cyngn/eleven/recycler/RecycleHolder.java +++ b/src/com/cyngn/eleven/recycler/RecycleHolder.java @@ -14,6 +14,7 @@ package com.cyngn.eleven.recycler; import android.view.View; import android.widget.AbsListView.RecyclerListener; +import com.cyngn.eleven.cache.ImageWorker; import com.cyngn.eleven.ui.MusicHolder; /** @@ -42,6 +43,7 @@ public class RecycleHolder implements RecyclerListener { // Release mImage's reference if (holder.mImage.get() != null) { + ImageWorker.cancelWork(holder.mImage.get()); holder.mImage.get().setImageDrawable(null); holder.mImage.get().setImageBitmap(null); } diff --git a/src/com/cyngn/eleven/ui/activities/PlaylistDetailActivity.java b/src/com/cyngn/eleven/ui/activities/PlaylistDetailActivity.java index 4bab42b..13f3b29 100644 --- a/src/com/cyngn/eleven/ui/activities/PlaylistDetailActivity.java +++ b/src/com/cyngn/eleven/ui/activities/PlaylistDetailActivity.java @@ -22,6 +22,7 @@ import android.widget.TextView; import com.cyngn.eleven.Config; import com.cyngn.eleven.R; import com.cyngn.eleven.adapters.ProfileSongAdapter; +import com.cyngn.eleven.cache.ImageFetcher; import com.cyngn.eleven.dragdrop.DragSortListView; import com.cyngn.eleven.dragdrop.DragSortListView.DragScrollProfile; import com.cyngn.eleven.dragdrop.DragSortListView.DropListener; @@ -116,8 +117,7 @@ public class PlaylistDetailActivity extends DetailActivity implements mNumberOfSongs = (TextView)findViewById(R.id.number_of_songs_text); mDurationOfPlaylist = (TextView)findViewById(R.id.duration_text); - // TODO: Get the top artist image - do this in the next patch - // ImageFetcher.getInstance(this).loadCurrentArtwork(mPlaylistImageView); + ImageFetcher.getInstance(this).loadPlaylistArtistImage(mPlaylistId, mPlaylistImageView); } private void setupSongList(ViewGroup root) { diff --git a/src/com/cyngn/eleven/ui/activities/ProfileActivity.java b/src/com/cyngn/eleven/ui/activities/ProfileActivity.java index 6b7951d..0fb8d4e 100644 --- a/src/com/cyngn/eleven/ui/activities/ProfileActivity.java +++ b/src/com/cyngn/eleven/ui/activities/ProfileActivity.java @@ -40,6 +40,7 @@ import com.cyngn.eleven.ui.fragments.profile.ArtistSongFragment; import com.cyngn.eleven.ui.fragments.profile.GenreSongFragment; import com.cyngn.eleven.ui.fragments.profile.LastAddedFragment; import com.cyngn.eleven.utils.ApolloUtils; +import com.cyngn.eleven.utils.ImageUtils; import com.cyngn.eleven.utils.MusicUtils; import com.cyngn.eleven.utils.NavUtils; import com.cyngn.eleven.utils.PreferenceUtils; @@ -489,7 +490,7 @@ public class ProfileActivity extends SlidingPanelActivity implements OnPageChang key = ImageFetcher.generateAlbumCacheKey(mProfileName, mArtistName); } - final Bitmap bitmap = ImageFetcher.decodeSampledBitmapFromFile(picturePath); + final Bitmap bitmap = ImageUtils.decodeSampledBitmapFromFile(picturePath); mImageFetcher.addBitmapToCache(key, bitmap); if (isAlbum()) { mTabCarousel.getAlbumArt().setImageBitmap(bitmap); diff --git a/src/com/cyngn/eleven/utils/ImageUtils.java b/src/com/cyngn/eleven/utils/ImageUtils.java new file mode 100644 index 0000000..69675bf --- /dev/null +++ b/src/com/cyngn/eleven/utils/ImageUtils.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2014 Cyanogen, Inc. + */ +package com.cyngn.eleven.utils; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.widget.ImageView; + +import com.cyngn.eleven.cache.BitmapWorkerTask; +import com.cyngn.eleven.cache.ImageCache; +import com.cyngn.eleven.cache.ImageWorker; +import com.cyngn.eleven.lastfm.Album; +import com.cyngn.eleven.lastfm.Artist; +import com.cyngn.eleven.lastfm.ImageSize; +import com.cyngn.eleven.lastfm.MusicEntry; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class ImageUtils { + private static final String DEFAULT_HTTP_CACHE_DIR = "http"; //$NON-NLS-1$ + + public static final int IO_BUFFER_SIZE_BYTES = 1024; + + private static final int DEFAULT_MAX_IMAGE_HEIGHT = 1024; + + private static final int DEFAULT_MAX_IMAGE_WIDTH = 1024; + + /** + * Gets the image url based on the imageType + * @param artistName The artist name param used in the Last.fm API. + * @param albumName The album name param used in the Last.fm API. + * @param imageType The type of image URL to fetch for. + * @return The image URL for an artist image or album image. + */ + public static String processImageUrl(final Context context, final String artistName, + final String albumName, final ImageWorker.ImageType imageType) { + switch (imageType) { + case ARTIST: + if (!TextUtils.isEmpty(artistName)) { + if (PreferenceUtils.getInstance(context).downloadMissingArtistImages()) { + final Artist artist = Artist.getInfo(context, artistName); + if (artist != null) { + return getBestImage(artist); + } + } + } + break; + case ALBUM: + if (!TextUtils.isEmpty(artistName) && !TextUtils.isEmpty(albumName)) { + if (PreferenceUtils.getInstance(context).downloadMissingArtwork()) { + final Artist correction = Artist.getCorrection(context, artistName); + if (correction != null) { + final Album album = Album.getInfo(context, correction.getName(), + albumName); + if (album != null) { + return getBestImage(album); + } + } + } + } + break; + default: + break; + } + return null; + } + + /** + * Downloads the bitmap from the url and returns it after some processing + * + * @param key The key to identify which image to process, as provided by + * {@link ImageWorker#loadImage(mKey, android.widget.ImageView)} + * @return The processed {@link Bitmap}. + */ + public static Bitmap processBitmap(final Context context, final String url) { + if (url == null) { + return null; + } + final File file = downloadBitmapToFile(context, url, DEFAULT_HTTP_CACHE_DIR); + if (file != null) { + // Return a sampled down version + final Bitmap bitmap = decodeSampledBitmapFromFile(file.toString()); + file.delete(); + if (bitmap != null) { + return bitmap; + } + } + return null; + } + + /** + * Decode and sample down a {@link Bitmap} from a file to the requested + * width and height. + * + * @param filename The full path of the file to decode + * @param reqWidth The requested width of the resulting bitmap + * @param reqHeight The requested height of the resulting bitmap + * @return A {@link Bitmap} sampled down from the original with the same + * aspect ratio and dimensions that are equal to or greater than the + * requested width and height + */ + public static Bitmap decodeSampledBitmapFromFile(final String filename) { + + // First decode with inJustDecodeBounds=true to check dimensions + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(filename, options); + + // Calculate inSampleSize + options.inSampleSize = calculateInSampleSize(options, DEFAULT_MAX_IMAGE_WIDTH, + DEFAULT_MAX_IMAGE_HEIGHT); + + // Decode bitmap with inSampleSize set + options.inJustDecodeBounds = false; + return BitmapFactory.decodeFile(filename, options); + } + + /** + * Calculate an inSampleSize for use in a + * {@link android.graphics.BitmapFactory.Options} object when decoding + * bitmaps using the decode* methods from {@link BitmapFactory}. This + * implementation calculates the closest inSampleSize that will result in + * the final decoded bitmap having a width and height equal to or larger + * than the requested width and height. This implementation does not ensure + * a power of 2 is returned for inSampleSize which can be faster when + * decoding but results in a larger bitmap which isn't as useful for caching + * purposes. + * + * @param options An options object with out* params already populated (run + * through a decode* method with inJustDecodeBounds==true + * @param reqWidth The requested width of the resulting bitmap + * @param reqHeight The requested height of the resulting bitmap + * @return The value to be used for inSampleSize + */ + public static final int calculateInSampleSize(final BitmapFactory.Options options, + final int reqWidth, final int reqHeight) { + /* Raw height and width of image */ + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + if (width > height) { + inSampleSize = Math.round((float)height / (float)reqHeight); + } else { + inSampleSize = Math.round((float)width / (float)reqWidth); + } + + // This offers some additional logic in case the image has a strange + // aspect ratio. For example, a panorama may have a much larger + // width than height. In these cases the total pixels might still + // end up being too large to fit comfortably in memory, so we should + // be more aggressive with sample down the image (=larger + // inSampleSize). + + final float totalPixels = width * height; + + /* More than 2x the requested pixels we'll sample down further */ + final float totalReqPixelsCap = reqWidth * reqHeight * 2; + + while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) { + inSampleSize++; + } + } + return inSampleSize; + } + + private static String getBestImage(MusicEntry e) { + final ImageSize[] QUALITY = {ImageSize.EXTRALARGE, ImageSize.LARGE, ImageSize.MEDIUM, + ImageSize.SMALL, ImageSize.UNKNOWN}; + for(ImageSize q : QUALITY) { + String url = e.getImageURL(q); + if (url != null) { + return url; + } + } + return null; + } + + /** + * Download a {@link Bitmap} from a URL, write it to a disk and return the + * File pointer. This implementation uses a simple disk cache. + * + * @param context The context to use + * @param urlString The URL to fetch + * @return A {@link File} pointing to the fetched bitmap + */ + public static final File downloadBitmapToFile(final Context context, final String urlString, + final String uniqueName) { + final File cacheDir = ImageCache.getDiskCacheDir(context, uniqueName); + + if (!cacheDir.exists()) { + cacheDir.mkdir(); + } + + HttpURLConnection urlConnection = null; + BufferedOutputStream out = null; + + try { + final File tempFile = File.createTempFile("bitmap", null, cacheDir); //$NON-NLS-1$ + + final URL url = new URL(urlString); + urlConnection = (HttpURLConnection)url.openConnection(); + if (urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) { + return null; + } + final InputStream in = new BufferedInputStream(urlConnection.getInputStream(), + IO_BUFFER_SIZE_BYTES); + out = new BufferedOutputStream(new FileOutputStream(tempFile), IO_BUFFER_SIZE_BYTES); + + int oneByte; + while ((oneByte = in.read()) != -1) { + out.write(oneByte); + } + return tempFile; + } catch (final IOException ignored) { + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + if (out != null) { + try { + out.close(); + } catch (final IOException ignored) { + } + } + } + return null; + } +} -- cgit v1.2.3