/* * Copyright (C) 2014 Fastboot Mobile, LLC. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See * the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program; * if not, see <http://www.gnu.org/licenses>. */ package com.fastbootmobile.encore.api.echonest; import android.content.Context; import android.content.SharedPreferences; import android.os.Handler; import android.os.RemoteException; import android.support.annotation.Nullable; import android.util.Log; import com.echonest.api.v4.EchoNestException; import com.fastbootmobile.encore.app.R; import com.fastbootmobile.encore.utils.Utils; import com.fastbootmobile.encore.framework.PlaybackProxy; import com.fastbootmobile.encore.model.Song; import com.fastbootmobile.encore.providers.ProviderAggregator; import com.fastbootmobile.encore.providers.ProviderIdentifier; import com.fastbootmobile.encore.service.BasePlaybackCallback; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.TreeSet; /** * Manages the AutoMix buckets and ensures AutoMix playback */ public class AutoMixManager extends BasePlaybackCallback { private static final String TAG = "AutoMixManager"; private static final String SHARED_PREFS = "automix_buckets"; private static final String PREF_BUCKETS_IDS = "buckets_ids"; private static final String PREF_PREFIX_NAME = "bucket_name_"; private static final String PREF_PREFIX_STYLES = "bucket_styles_"; private static final String PREF_PREFIX_MOODS = "bucket_moods_"; private static final String PREF_PREFIX_TASTE = "bucket_taste_"; private static final String PREF_PREFIX_ADVENTUROUS = "bucket_adventurous_"; private static final String PREF_PREFIX_SONG_TYPES = "bucket_song_types_"; private static final String PREF_PREFIX_SPEECHINESS = "bucket_speechiness_"; private static final String PREF_PREFIX_ENERGY = "bucket_energy_"; private static final String PREF_PREFIX_FAMILIAR = "bucket_familiar_"; private Context mContext; private List<AutoMixBucket> mBuckets; private AutoMixBucket mCurrentPlayingBucket; private Runnable mGetNextTrackRunnable; private Handler mHandler; private List<String> mActiveBucketRefHistory; private static final AutoMixManager INSTANCE = new AutoMixManager(); private AutoMixManager() { mGetNextTrackRunnable = new Runnable() { @Override public void run() { for (int i = 0; i < 2; ++i) { try { // Fetch the next track String nextTrackRef = mCurrentPlayingBucket.getNextTrack(); // Queue it Song nextTrack = getSongFromRef(nextTrackRef); if (nextTrack != null) { // TODO: Check if track is available, to avoid queuing tracks that // aren't available. The tricky part here is unloaded tracks (for Spotify // at least) shows as not available until they're loaded. We'd need to // add some callback handling too. PlaybackProxy.queueSong(nextTrack, false); } mActiveBucketRefHistory.add(nextTrackRef); } catch (EchoNestException e) { Log.e(TAG, "Unable to get next track", e); } } } }; } public static AutoMixManager getDefault() { return INSTANCE; } /** * Initializes the AutoMix manager * @param ctx A valid application context */ public void initialize(Context ctx) { mContext = ctx; mBuckets = new ArrayList<>(); readBucketsFromPrefs(); mHandler = new Handler(); mActiveBucketRefHistory = new ArrayList<>(); } /** * Returns the shared preferences * @return The SharedPreferences storing the buckets */ private SharedPreferences getPrefs() { return mContext.getSharedPreferences(SHARED_PREFS, 0); } /** * Restore the AutoMix buckets stored in SharedPreferences */ public void readBucketsFromPrefs() { SharedPreferences prefs = mContext.getSharedPreferences(SHARED_PREFS, 0); Set<String> buckets = prefs.getStringSet(PREF_BUCKETS_IDS, new TreeSet<String>()); mBuckets.clear(); for (String bucketId : buckets) { AutoMixBucket bucket = restoreBucketFromId(bucketId); mBuckets.add(bucket); } } /** * Creates a new AutoMix Bucket with the provided settings * @param name The name of the bucket * @param styles The EchoNest styles to include in the bucket * @param moods The EchoNest moods to include in the bucket * @param taste Whether or not to use the user's taste profile * @param adventurous The target adventurousness level [0.0-1.0] * @param songTypes The EchoNest song types to include * @param speechiness The target speechiness level [0.0-1.0] * @param energy The target energy level [0.0-1.0] * @param familiar The target familiarity level [0.0-1.0] * @return The generated {@link com.fastbootmobile.encore.api.echonest.AutoMixBucket} */ public AutoMixBucket createBucket(String name, String[] styles, String[] moods, boolean taste, float adventurous, String[] songTypes, float speechiness, float energy, float familiar) { AutoMixBucket bucket = new AutoMixBucket(name, styles, moods, taste, adventurous, songTypes, speechiness, energy, familiar); bucket.createPlaylistSession(); mBuckets.add(bucket); saveBucket(bucket); return bucket; } /** * Creates a new AutoMix Bucket with the provided settings that will be used for a static * playlist instead of a dynamic bucket * @param name The name of the bucket * @param styles The EchoNest styles to include in the bucket * @param moods The EchoNest moods to include in the bucket * @param taste Whether or not to use the user's taste profile * @param adventurous The target adventurousness level [0.0-1.0] * @param songTypes The EchoNest song types to include * @param speechiness The target speechiness level [0.0-1.0] * @param energy The target energy level [0.0-1.0] * @param familiar The target familiarity level [0.0-1.0] * @return The generated {@link com.fastbootmobile.encore.api.echonest.AutoMixBucket} */ public AutoMixBucket createStaticBucket(String name, String[] styles, String[] moods, boolean taste, float adventurous, String[] songTypes, float speechiness, float energy, float familiar) { return new AutoMixBucket(name, styles, moods, taste, adventurous, songTypes, speechiness, energy, familiar); } /** * Saves a bucket (ie. its parameters) to the internal storage * @param bucket The bucket to save */ private void saveBucket(AutoMixBucket bucket) { if (!bucket.isPlaylistSessionError()) { SharedPreferences prefs = getPrefs(); SharedPreferences.Editor editor = prefs.edit(); final String id = bucket.getSessionId(); if (id != null) { editor.putString(PREF_PREFIX_NAME + id, bucket.mName); editor.putFloat(PREF_PREFIX_ADVENTUROUS + id, bucket.mAdventurousness); editor.putFloat(PREF_PREFIX_ENERGY + id, bucket.mEnergy); editor.putFloat(PREF_PREFIX_FAMILIAR + id, bucket.mFamiliar); editor.putString(PREF_PREFIX_MOODS + id, Utils.implode(bucket.mMoods, ",")); editor.putString(PREF_PREFIX_SONG_TYPES + id, Utils.implode(bucket.mSongTypes, ",")); editor.putFloat(PREF_PREFIX_SPEECHINESS + id, bucket.mSpeechiness); editor.putString(PREF_PREFIX_STYLES + id, Utils.implode(bucket.mStyles, ",")); editor.putBoolean(PREF_PREFIX_TASTE + id, bucket.mUseTaste); Set<String> set = new TreeSet<>(prefs.getStringSet(PREF_BUCKETS_IDS, new TreeSet<String>())); set.add(id); editor.putStringSet(PREF_BUCKETS_IDS, set); editor.apply(); } } else { Log.e(TAG, "Cannot save bucket: playlist session is in error state"); } } /** * Restores/recreate an AutoMix bucket from an existing session ID * @param id The session ID to restore * @return An {@link com.fastbootmobile.encore.api.echonest.AutoMixBucket} regenerated from the provided * bucket ID. */ public AutoMixBucket restoreBucketFromId(final String id) { SharedPreferences prefs = getPrefs(); return new AutoMixBucket( prefs.getString(PREF_PREFIX_NAME + id, null), prefs.getString(PREF_PREFIX_STYLES + id, "").split(","), prefs.getString(PREF_PREFIX_MOODS + id, "").split(","), prefs.getBoolean(PREF_PREFIX_TASTE + id, false), prefs.getFloat(PREF_PREFIX_ADVENTUROUS + id, -1), prefs.getString(PREF_PREFIX_SONG_TYPES + id, "").split(","), prefs.getFloat(PREF_PREFIX_SPEECHINESS + id, -1), prefs.getFloat(PREF_PREFIX_ENERGY + id, -1), prefs.getFloat(PREF_PREFIX_FAMILIAR + id, -1), id ); } /** * Returns the list of existing buckets * @return The list of existing buckets */ public List<AutoMixBucket> getBuckets() { return mBuckets; } /** * @return The currently playing bucket */ public AutoMixBucket getCurrentPlayingBucket() { return mCurrentPlayingBucket; } /** * Starts playing a bucket * @param bucket The bucket to play */ public void startPlay(AutoMixBucket bucket) { // Ensure bucket is ready if (!bucket.isPlaylistSessionReady()) { Log.e(TAG, "Cannot play bucket " + bucket.getName() + ": Session not ready"); return; } // Queue tracks try { String trackRef = null; // Try to get the first track, with 5 tries (in case of bad network or temporary error) int tryCount = 0; while (trackRef == null && tryCount < 5) { trackRef = bucket.getNextTrack(); tryCount++; } if (trackRef == null) { Log.e(TAG, "Track Reference is still null after 5 attempts"); mHandler.post(new Runnable() { public void run() { Utils.shortToast(mContext, R.string.bucket_track_failure); } }); mCurrentPlayingBucket = null; } else { Song s = getSongFromRef(trackRef); if (s != null) { mActiveBucketRefHistory.clear(); mActiveBucketRefHistory.add(s.getRef()); mCurrentPlayingBucket = bucket; PlaybackProxy.playSong(s); } else { Log.e(TAG, "Song is null! Cannot find it back"); } } } catch (EchoNestException e) { if (e.getCode() == 5 && e.getMessage().contains("does not exist") && !bucket.isPlaylistSessionError()) { // The bucket has expired, we must recreate it and restart the play procedure Log.d(TAG, "The bucket has expired, we must recreate it and restart the play procedure"); bucket.createPlaylistSession(); startPlay(bucket); } else { Log.e(TAG, "Unable to get next track from bucket", e); } } } /** * Destroys the provided bucket * * @param bucket The bucket to remove */ public void destroyBucket(AutoMixBucket bucket) { SharedPreferences prefs = getPrefs(); SharedPreferences.Editor editor = prefs.edit(); Set<String> set = new TreeSet<>(prefs.getStringSet(PREF_BUCKETS_IDS, new TreeSet<String>())); set.remove(bucket.getSessionId()); editor.putStringSet(PREF_BUCKETS_IDS, set); editor.apply(); mBuckets.remove(bucket); } /** * Retrieves a Song from the preferred Rosetta-stone provider * @param ref The reference of the song * @return A Song, or null if the provider couldn't retrieve it */ private @Nullable Song getSongFromRef(String ref) { ProviderAggregator aggregator = ProviderAggregator.getDefault(); // Try to see if we have that song in our cache already Song s = aggregator.retrieveSong(ref, null); if (s == null) { // The ref is not in cache, try to get it from the preferred rosetta stone provider String prefix = aggregator.getPreferredRosettaStonePrefix(); if (prefix != null) { ProviderIdentifier id = aggregator.getRosettaStoneIdentifier(prefix); s = aggregator.retrieveSong(ref, id); } } return s; } @Override public void onSongStarted(boolean buffering, Song s) throws RemoteException { if (mCurrentPlayingBucket != null) { if (buffering) { if (!mActiveBucketRefHistory.contains(s.getRef())) { // Song started is not the one we expected from the bucket, cancel automix playback Log.d(TAG, "Cancelling automix playback: Playing " + s.getRef() + ", not in active history"); mCurrentPlayingBucket = null; } else { // We're playing the song we expected to be played from the bucket, fetch the next one. Log.d(TAG, "Fetching next automix track"); new Thread(mGetNextTrackRunnable).start(); } } } } }