package com.marverenic.music.player; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.media.AudioManager; import android.media.MediaPlayer; import android.os.Handler; import android.os.PowerManager; import android.support.annotation.NonNull; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaButtonReceiver; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.view.KeyEvent; import com.marverenic.music.JockeyApplication; import com.marverenic.music.R; import com.marverenic.music.activity.MainActivity; import com.marverenic.music.data.store.MediaStoreUtil; import com.marverenic.music.data.store.PlayCountStore; import com.marverenic.music.data.store.PreferenceStore; import com.marverenic.music.data.store.ReadOnlyPreferenceStore; import com.marverenic.music.data.store.RemotePreferenceStore; import com.marverenic.music.data.store.SharedPreferenceStore; import com.marverenic.music.model.Song; import com.marverenic.music.utils.Internal; import com.marverenic.music.utils.Util; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; import java.util.Scanner; import javax.inject.Inject; import timber.log.Timber; import static android.content.Intent.ACTION_HEADSET_PLUG; import static android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY; /** * High level implementation for a MediaPlayer. MusicPlayer is backed by a {@link QueuedMediaPlayer} * and provides high-level behavior definitions (for actions like {@link #skip()}, * {@link #skipPrevious()} and {@link #togglePlay()}) as well as system integration. * * MediaPlayer provides shuffle and repeat with {@link #setShuffle(boolean)} and * {@link #setRepeat(int)}, respectively. * * MusicPlayer also provides play count logging and state reloading. * See {@link #logPlayCount(Song, boolean)}, {@link #loadState()} and {@link #saveState()} * * System integration is implemented by handling Audio Focus through {@link AudioManager}, attaching * a {@link MediaSessionCompat}, and with a {@link HeadsetListener} -- an implementation of * {@link BroadcastReceiver} that pauses playback when headphones are disconnected. */ public class MusicPlayer implements AudioManager.OnAudioFocusChangeListener, QueuedMediaPlayer.PlaybackEventListener { private static final String TAG = "MusicPlayer"; /** * The filename of the queue state used to load and save previous configurations. * This file will be stored in the directory defined by * {@link Context#getExternalFilesDir(String)} */ private static final String QUEUE_FILE = ".queue"; /** * An {@link Intent} action broadcasted when a MusicPlayer has changed its state automatically */ public static final String UPDATE_BROADCAST = "marverenic.jockey.player.REFRESH"; /** * An {@link Intent} extra sent with {@link #UPDATE_BROADCAST} intents which maps to a boolean * representing whether or not the update is a minor update (i.e. an update that was triggered * by the user). */ public static final String UPDATE_EXTRA_MINOR = "marverenic.jockey.player.REFRESH:minor"; /** * An {@link Intent} action broadcasted when a MusicPlayer has information that should be * presented to the user * @see #INFO_EXTRA_MESSAGE */ public static final String INFO_BROADCAST = "marverenic.jockey.player.INFO"; /** * An {@link Intent} extra sent with {@link #INFO_BROADCAST} intents which maps to a * user-friendly information message */ public static final String INFO_EXTRA_MESSAGE = "marverenic.jockey.player.INFO:MSG"; /** * An {@link Intent} action broadcasted when a MusicPlayer has encountered an error when * setting the current playback source * @see #ERROR_EXTRA_MSG */ public static final String ERROR_BROADCAST = "marverenic.jockey.player.ERROR"; /** * An {@link Intent} extra sent with {@link #ERROR_BROADCAST} intents which maps to a * user-friendly error message */ public static final String ERROR_EXTRA_MSG = "marverenic.jockey.player.ERROR:MSG"; /** * Repeat value that corresponds to repeat none. Playback will continue as normal until and will * end after the last song finishes * @see #setRepeat(int) */ public static final int REPEAT_NONE = 0; /** * Repeat value that corresponds to repeat all. Playback will continue as normal, but the queue * will restart from the beginning once the last song finishes * @see #setRepeat(int) */ public static final int REPEAT_ALL = -1; /** * Repeat value that corresponds to repeat one. When the current song is finished, it will be * repeated. The MusicPlayer will never progress to the next track until the user manually * changes the song. * @see #setRepeat(int) */ public static final int REPEAT_ONE = -2; /** * Defines the threshold for skip previous behavior. If the current seek position in the song is * greater than this value, {@link #skipPrevious()} will seek to the beginning of the song. * If the current seek position is less than this threshold, then the queue index will be * decremented and the previous song in the queue will be played. * This value is measured in milliseconds and is currently set to 5 seconds * @see #skipPrevious() */ private static final int SKIP_PREVIOUS_THRESHOLD = 5000; /** * Defines the minimum duration that must be passed for a song to be considered "played" when * logging play counts * This value is measured in milliseconds and is currently set to 24 seconds */ private static final int PLAY_COUNT_THRESHOLD = 24000; /** * Defines the maximum duration that a song can reach to be considered "skipped" when logging * play counts * This value is measured in milliseconds and is currently set to 20 seconds */ private static final int SKIP_COUNT_THRESHOLD = 20000; /** * The volume scalar to set when {@link AudioManager} causes a MusicPlayer instance to duck */ private static final float DUCK_VOLUME = 0.5f; private QueuedMediaPlayer mMediaPlayer; private Context mContext; private Handler mHandler; private MediaSessionCompat mMediaSession; private HeadsetListener mHeadphoneListener; private OnPlaybackChangeListener mCallback; private List<Song> mQueue; private List<Song> mQueueShuffled; private boolean mShuffle; private int mRepeat; private int mMultiRepeat; /** * Whether this MusicPlayer has focus from {@link AudioManager} to play audio * @see #getFocus() */ private boolean mFocused = false; /** * Whether playback should continue once {@link AudioManager} returns focus to this MusicPlayer * @see #onAudioFocusChange(int) */ private boolean mResumeOnFocusGain = false; private boolean mResumeOnHeadphonesConnect; /** * The album artwork of the current song */ private Bitmap mArtwork; @Inject PlayCountStore mPlayCountStore; private RemotePreferenceStore mRemotePreferenceStore; private final Runnable mSleepTimerRunnable = this::onSleepTimerEnd; /** * Creates a new MusicPlayer with an empty queue. The backing {@link android.media.MediaPlayer} * will create a wakelock (specified by {@link PowerManager#PARTIAL_WAKE_LOCK}), and all * system integration will be initialized * @param context A Context used to interact with other components of the OS and used to * load songs. This Context will be kept for the lifetime of this Object. */ public MusicPlayer(Context context) { mContext = context; mHandler = new Handler(); JockeyApplication.getComponent(mContext).inject(this); mRemotePreferenceStore = new RemotePreferenceStore(mContext); // Initialize play count store mPlayCountStore.refresh() .subscribe(complete -> { Timber.i("init: Initialized play count store values"); }, throwable -> { Timber.e(throwable, "init: Failed to read play count store values"); }); // Initialize the media player mMediaPlayer = new QueuedExoPlayer(context); mMediaPlayer.setPlaybackEventListener(this); mQueue = new ArrayList<>(); mQueueShuffled = new ArrayList<>(); // Attach a HeadsetListener to respond to headphone events mHeadphoneListener = new HeadsetListener(this); IntentFilter filter = new IntentFilter(); filter.addAction(ACTION_HEADSET_PLUG); filter.addAction(ACTION_AUDIO_BECOMING_NOISY); context.registerReceiver(mHeadphoneListener, filter); loadPrefs(); initMediaSession(); } /** * Reloads shuffle and repeat preferences from {@link SharedPreferences} */ private void loadPrefs() { Timber.i("Loading SharedPreferences..."); // SharedPreferenceStore is backed by an instance of SharedPreferences. Because // SharedPreferences isn't safe to use across processes, the only time we can get valid // data is right after we open the SharedPreferences for the first time in this process. // // We're going to take advantage of that here so that we can load the latest preferences // as soon as the MusicPlayer is started (which should be the same time that this process // is started). To update these preferences, see updatePreferences(preferenceStore) PreferenceStore preferenceStore = new SharedPreferenceStore(mContext); mShuffle = preferenceStore.isShuffled(); setRepeat(preferenceStore.getRepeatMode()); setMultiRepeat(mRemotePreferenceStore.getMultiRepeatCount()); initEqualizer(preferenceStore); startSleepTimer(mRemotePreferenceStore.getSleepTimerEndTime()); mResumeOnHeadphonesConnect = preferenceStore.resumeOnHeadphonesConnect(); } /** * Updates shuffle and repeat preferences from a Preference Store * @param preferencesStore The preference store to read values from */ public void updatePreferences(ReadOnlyPreferenceStore preferencesStore) { Timber.i("Updating preferences..."); if (preferencesStore.isShuffled() != mShuffle) { setShuffle(preferencesStore.isShuffled()); } mResumeOnHeadphonesConnect = preferencesStore.resumeOnHeadphonesConnect(); setRepeat(preferencesStore.getRepeatMode()); initEqualizer(preferencesStore); } /** * Initiate a MediaSession to allow the Android system to interact with the player */ private void initMediaSession() { Timber.i("Initializing MediaSession"); MediaSessionCompat session = new MediaSessionCompat(mContext, TAG, null, null); session.setCallback(new MediaSessionCallback(this)); session.setSessionActivity( PendingIntent.getActivity( mContext, 0, MainActivity.newNowPlayingIntent(mContext) .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_CANCEL_CURRENT)); session.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder() .setActions(PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_SEEK_TO | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat.ACTION_STOP) .setState(PlaybackStateCompat.STATE_NONE, 0, 0f); Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); mediaButtonIntent.setClass(mContext, MediaButtonReceiver.class); PendingIntent mbrIntent = PendingIntent.getBroadcast(mContext, 0, mediaButtonIntent, 0); session.setMediaButtonReceiver(mbrIntent); session.setPlaybackState(state.build()); session.setActive(true); mMediaSession = session; } /** * Reload all equalizer settings from SharedPreferences */ private void initEqualizer(ReadOnlyPreferenceStore preferencesStore) { Timber.i("Initializing equalizer"); mMediaPlayer.setEqualizer(preferencesStore.getEqualizerEnabled(), preferencesStore.getEqualizerSettings()); } /** * Saves the player's current state to a file with the name {@link #QUEUE_FILE} in * the app's external files directory specified by {@link Context#getExternalFilesDir(String)} * @throws IOException * @see #loadState() */ public void saveState() throws IOException { Timber.i("Saving player state"); // Anticipate the outcome of a command so that if we're killed right after it executes, // we can restore to the proper state int reloadSeekPosition = mMediaPlayer.getCurrentPosition(); int reloadQueuePosition = mMediaPlayer.getQueueIndex(); final String currentPosition = Integer.toString(reloadSeekPosition); final String queuePosition = Integer.toString(reloadQueuePosition); final String queueLength = Integer.toString(mQueue.size()); StringBuilder queue = new StringBuilder(); for (Song s : mQueue) { queue.append(' ').append(s.getSongId()); } StringBuilder queueShuffled = new StringBuilder(); for (Song s : mQueueShuffled) { queueShuffled.append(' ').append(s.getSongId()); } String output = currentPosition + " " + queuePosition + " " + queueLength + queue + queueShuffled; File save = new File(mContext.getExternalFilesDir(null), QUEUE_FILE); FileOutputStream stream = null; try { stream = new FileOutputStream(save); stream.write(output.getBytes()); } finally { if (stream != null) { stream.close(); } } } /** * Reloads a saved state * @see #saveState() */ public void loadState() { Timber.i("Loading state..."); Scanner scanner = null; try { File save = new File(mContext.getExternalFilesDir(null), QUEUE_FILE); scanner = new Scanner(save); int currentPosition = scanner.nextInt(); int queuePosition = scanner.nextInt(); int queueLength = scanner.nextInt(); long[] queueIDs = new long[queueLength]; for (int i = 0; i < queueLength; i++) { queueIDs[i] = scanner.nextInt(); } mQueue = MediaStoreUtil.buildSongListFromIds(queueIDs, mContext); long[] shuffleQueueIDs; if (scanner.hasNextInt()) { shuffleQueueIDs = new long[queueLength]; for (int i = 0; i < queueLength; i++) { shuffleQueueIDs[i] = scanner.nextInt(); } mQueueShuffled = MediaStoreUtil.buildSongListFromIds(shuffleQueueIDs, mContext); } else if (mShuffle) { shuffleQueue(queuePosition); } setBackingQueue(queuePosition); mMediaPlayer.seekTo(currentPosition); mArtwork = Util.fetchFullArt(mContext, getNowPlaying()); } catch(FileNotFoundException ignored) { Timber.i("State does not exist. Using empty state"); // If there's no queue file, just restore to an empty state } catch (IllegalArgumentException|NoSuchElementException e) { Timber.i(e, "Failed to parse previous state. Resetting..."); mQueue.clear(); mQueueShuffled.clear(); mMediaPlayer.reset(); } finally { if (scanner != null) { scanner.close(); } } } /** * Sets a callback for when the current song changes (no matter the source of the change) * @param listener The callback to be registered. {@code null} may be passed in to remove a * previously registered listener. Only one callback may be registered at * a time. */ public void setPlaybackChangeListener(OnPlaybackChangeListener listener) { mCallback = listener; } /** * Updates the metadata in the attached {@link MediaSessionCompat} */ private void updateMediaSession() { Timber.i("Updating MediaSession"); if (getNowPlaying() != null) { Song nowPlaying = getNowPlaying(); MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, nowPlaying.getSongName()) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, nowPlaying.getSongName()) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, nowPlaying.getAlbumName()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, nowPlaying.getAlbumName()) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, nowPlaying.getArtistName()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, nowPlaying.getArtistName()) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, getDuration()) .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, mArtwork); mMediaSession.setMetadata(metadataBuilder.build()); PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder().setActions( PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_SEEK_TO | PlaybackStateCompat.ACTION_STOP | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS); if (mMediaPlayer.isPlaying()) { state.setState(PlaybackStateCompat.STATE_PLAYING, getCurrentPosition(), 1f); } else if (mMediaPlayer.isPaused()) { state.setState(PlaybackStateCompat.STATE_PAUSED, getCurrentPosition(), 1f); } else if (mMediaPlayer.isStopped()) { state.setState(PlaybackStateCompat.STATE_STOPPED, getCurrentPosition(), 1f); } else { state.setState(PlaybackStateCompat.STATE_NONE, getCurrentPosition(), 1f); } mMediaSession.setPlaybackState(state.build()); } Timber.i("Sending minor broadcast to update UI process"); Intent broadcast = new Intent(UPDATE_BROADCAST) .putExtra(UPDATE_EXTRA_MINOR, true); mContext.sendBroadcast(broadcast, null); } @Override public void onAudioFocusChange(int focusChange) { Timber.i("AudioFocus changed (%d)", focusChange); switch (focusChange) { case AudioManager.AUDIOFOCUS_LOSS: Timber.i("Focus lost. Pausing music."); mFocused = false; mResumeOnFocusGain = false; pause(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: Timber.i("Focus lost transiently. Pausing music."); boolean resume = isPlaying() || mResumeOnFocusGain; mFocused = false; pause(); mResumeOnFocusGain = resume; break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: Timber.i("Focus lost transiently. Ducking."); mMediaPlayer.setVolume(DUCK_VOLUME); break; case AudioManager.AUDIOFOCUS_GAIN: Timber.i("Regained AudioFocus"); mMediaPlayer.setVolume(1f); if (mResumeOnFocusGain) play(); mResumeOnFocusGain = false; break; default: Timber.i("Ignoring AudioFocus state change"); break; } updateNowPlaying(); updateUi(); } @Internal boolean shouldResumeOnHeadphonesConnect() { AudioManager manager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); return mResumeOnHeadphonesConnect && (mFocused || !manager.isMusicActive()); } /** * Notifies the attached {@link MusicPlayer.OnPlaybackChangeListener} that a playback change * has occurred, and updates the attached {@link MediaSessionCompat} */ private void updateNowPlaying() { Timber.i("updateNowPlaying() called"); updateMediaSession(); if (mCallback != null) { mCallback.onPlaybackChange(); } } /** * Called to notify the UI thread to refresh any player data when the player changes states * on its own (Like when a song finishes) */ protected void updateUi() { Timber.i("Sending broadcast to update UI process"); Intent broadcast = new Intent(UPDATE_BROADCAST) .putExtra(UPDATE_EXTRA_MINOR, false); mContext.sendBroadcast(broadcast, null); } /** * Called to notify the UI thread that an error has occurred. The typical listener will show the * message passed in to the user. * @param message A user-friendly message associated with this error that may be shown in the UI */ protected void postError(String message) { Timber.i("Posting error to UI process: %s", message); mContext.sendBroadcast( new Intent(ERROR_BROADCAST).putExtra(ERROR_EXTRA_MSG, message), null); } /** * Called to notify the UI thread of a non-critical event. The typical listener will show the * message passed in to the user * @param message A user-friendly message associated with this event that may be shown in the UI */ protected void postInfo(String message) { Timber.i("Posting info to UI process: %s", message); mContext.sendBroadcast( new Intent(INFO_BROADCAST).putExtra(INFO_EXTRA_MESSAGE, message), null); } /** * Gain Audio focus from the system if we don't already have it * @return whether we have gained focus (or already had it) */ private boolean getFocus() { if (!mFocused) { Timber.i("Requesting AudioFocus..."); AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); int response = audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); mFocused = response == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; } return mFocused; } /** * Generates a new random permutation of the queue, and sets it as the backing * {@link QueuedMediaPlayer}'s queue * @param currentIndex The index of the current song which will be moved to the top of the * shuffled queue */ private void shuffleQueue(int currentIndex) { Timber.i("Shuffling queue..."); ArrayList<Song> shuffled = new ArrayList<>(mQueue); if (!shuffled.isEmpty()) { Song first = shuffled.remove(currentIndex); Collections.shuffle(shuffled); shuffled.add(0, first); } mQueueShuffled = Collections.unmodifiableList(shuffled); } /** * Called when disabling shuffle to ensure that any modifications made to the shuffled queue * are applied to the unshuffled queue. Currently, the only such modification is song deletions * since they are implemented on the client side. */ private void unshuffleQueue() { List<Song> unshuffled = new ArrayList<>(mQueue); List<Song> songs = new ArrayList<>(mQueueShuffled); Iterator<Song> unshuffledIterator = unshuffled.iterator(); while (unshuffledIterator.hasNext()) { Song song = unshuffledIterator.next(); if (!songs.remove(song)) { unshuffledIterator.remove(); } } mQueue = Collections.unmodifiableList(unshuffled); } /** * Toggles playback between playing and paused states * @see #play() * @see #pause() */ public void togglePlay() { Timber.i("Toggling playback"); if (isPlaying()) { pause(); } else if (mMediaPlayer.isComplete()) { mMediaPlayer.setQueueIndex(0); play(); } else { play(); } } /** * Pauses music playback */ public void pause() { Timber.i("Pausing playback"); if (isPlaying()) { mMediaPlayer.pause(); updateNowPlaying(); } mResumeOnFocusGain = false; } /** * Starts or resumes music playback */ public void play() { Timber.i("Resuming playback"); if (!isPlaying() && getFocus()) { mMediaPlayer.play(); updateNowPlaying(); } } /** * Skips to the next song in the queue and logs a play count or skip count * If repeat all is enabled, the queue will loop from the beginning when it it is finished. * Otherwise, calling this method when the last item in the queue is being played will stop * playback * @see #setRepeat(int) to set the current repeat mode */ public void skip() { Timber.i("Skipping song"); if (!mMediaPlayer.isComplete() && !mMediaPlayer.hasError()) { logPlay(); } setMultiRepeat(0); if (mMediaPlayer.getQueueIndex() < mQueue.size() - 1 || mRepeat == REPEAT_ALL) { // If we're in the middle of the queue, or repeat all is on, start the next song mMediaPlayer.skip(); } else { mMediaPlayer.setQueueIndex(0); mMediaPlayer.pause(); } } /** * Records a play or skip for the current song based on the current time of the backing * {@link MediaPlayer} as returned by {@link #getCurrentPosition()} */ private void logPlay() { Timber.i("Logging play count..."); if (getNowPlaying() != null) { if (getCurrentPosition() > PLAY_COUNT_THRESHOLD || getCurrentPosition() > getDuration() / 2) { // Log a play if we're passed a certain threshold or more than 50% in a song // (whichever is smaller) Timber.i("Marking song as played"); logPlayCount(getNowPlaying(), false); } else if (getCurrentPosition() < SKIP_COUNT_THRESHOLD) { // If we're not very far into this song, log a skip Timber.i("Marking song as skipped"); logPlayCount(getNowPlaying(), true); } else { Timber.i("Not writing play count. Song was neither played nor skipped."); } } } /** * Record a play or skip for a certain song * @param song the song to change the play count of * @param skip Whether the song was skipped (true if skipped, false if played) */ private void logPlayCount(Song song, boolean skip) { Timber.i("Logging %s count to PlayCountStore for %s...", (skip) ? "skip" : "play", song.toString()); if (skip) { mPlayCountStore.incrementSkipCount(song); } else { mPlayCountStore.incrementPlayCount(song); mPlayCountStore.setPlayDateToNow(song); } Timber.i("Writing PlayCountStore to disk..."); mPlayCountStore.save(); } /** * Skips to the previous song in the queue * If the song's current position is more than 5 seconds or 50% of the song (whichever is * smaller), then the song will be restarted from the beginning instead. * If this is called when the first item in the queue is being played, it will loop to the last * song if repeat all is enabled, otherwise the current song will always be restarted * @see #setRepeat(int) to set the current repeat mode */ public void skipPrevious() { Timber.i("skipPrevious() called"); if ((getQueuePosition() == 0 && mRepeat != REPEAT_ALL) || getCurrentPosition() > SKIP_PREVIOUS_THRESHOLD || getCurrentPosition() > getDuration() / 2) { Timber.i("Restarting current song..."); mMediaPlayer.seekTo(0); mMediaPlayer.play(); updateNowPlaying(); } else { Timber.i("Starting previous song..."); mMediaPlayer.skipPrevious(); } } /** * Stops music playback */ public void stop() { Timber.i("stop() called"); pause(); seekTo(0); } /** * Seek to a specified position in the current song * @param mSec The time (in milliseconds) to seek to * @see MediaPlayer#seekTo(int) */ public void seekTo(int mSec) { Timber.i("Seeking to %d", mSec); mMediaPlayer.seekTo(mSec); } /** * @return The {@link Song} that is currently being played */ public Song getNowPlaying() { return mMediaPlayer.getNowPlaying(); } /** * @return Whether music is being played or not * @see MediaPlayer#isPlaying() */ public boolean isPlaying() { return mMediaPlayer.isPlaying(); } /** * @return The current queue. If shuffle is enabled, then the shuffled queue will be returned, * otherwise the regular queue will be returned */ public List<Song> getQueue() { // If you're using this method on the UI thread, consider replacing this method with // return new ArrayList<>(mMediaPlayer.getQueue()); // to prevent components from accidentally changing the backing queue return mMediaPlayer.getQueue(); } /** * @return The current index in the queue that is being played */ public int getQueuePosition() { return mMediaPlayer.getQueueIndex(); } /** * @return The number of items in the current queue */ public int getQueueSize() { return mMediaPlayer.getQueueSize(); } /** * @return The current seek position of the song that is playing * @see MediaPlayer#getCurrentPosition() */ public int getCurrentPosition() { return mMediaPlayer.getCurrentPosition(); } /** * @return The length of the current song in milliseconds * @see MediaPlayer#getDuration() */ public int getDuration() { return mMediaPlayer.getDuration(); } /** * Changes the current index of the queue and starts playback from this new position * @param position The index in the queue to skip to * @throws IllegalArgumentException if {@code position} is not between 0 and the queue length */ public void changeSong(int position) { Timber.i("changeSong called (position = %d)", position); mMediaPlayer.setQueueIndex(position); play(); } /** * Changes the current queue and starts playback from the current index * @param queue The replacement queue * @see #setQueue(List, int) to change the current index simultaneously * @throws IllegalArgumentException if the current index cannot be applied to the updated queue */ public void setQueue(@NonNull List<Song> queue) { Timber.i("setQueue called (%d songs)", queue.size()); setQueue(queue, mMediaPlayer.getQueueIndex()); } /** * Changes the current queue and starts playback from the specified index * @param queue The replacement queue * @param index The index to start playback from * @throws IllegalArgumentException if {@code index} is not between 0 and the queue length */ public void setQueue(@NonNull List<Song> queue, int index) { Timber.i("setQueue called (%d songs)", queue.size()); // If you're using this method on the UI thread, consider replacing the first line in this // method with "mQueue = new ArrayList<>(queue);" // to prevent components from accidentally changing the backing queue mQueue = Collections.unmodifiableList(queue); if (mShuffle) { Timber.i("Shuffling new queue and starting from beginning"); shuffleQueue(index); setBackingQueue(0); } else { Timber.i("Setting new backing queue (starting at index %d)", index); setBackingQueue(index); } seekTo(0); } /** * Changes the order of the current queue without interrupting playback * @param queue The modified queue. This List should contain all of the songs currently in the * queue, but in a different order to prevent discrepancies between the shuffle * and non-shuffled queue. * @param index The index of the song that is currently playing in the modified queue */ public void editQueue(@NonNull List<Song> queue, int index) { Timber.i("editQueue called (index = %d)", index); if (mShuffle) { mQueueShuffled = Collections.unmodifiableList(queue); } else { mQueue = Collections.unmodifiableList(queue); } setBackingQueue(index); } /** * Helper method to push changes in the queue to the backing {@link QueuedMediaPlayer} * @see #setBackingQueue(int) */ private void setBackingQueue() { Timber.i("setBackingQueue() called"); setBackingQueue(mMediaPlayer.getQueueIndex()); } /** * Helper method to push changes in the queue to the backing {@link QueuedMediaPlayer}. This * method will set the queue to the appropriate shuffled or ordered list and apply the * specified index as the replacement queue position * @param index The new queue index to send to the backing {@link QueuedMediaPlayer}. */ private void setBackingQueue(int index) { Timber.i("setBackingQueue() called (index = %d)", index); if (mShuffle) { mMediaPlayer.setQueue(mQueueShuffled, index); } else { mMediaPlayer.setQueue(mQueue, index); } } /** * Sets the repeat option to control what happens when a track finishes. * @param repeat An integer representation of the repeat option. May be one of either * {@link #REPEAT_NONE}, {@link #REPEAT_ALL}, {@link #REPEAT_ONE}. */ public void setRepeat(int repeat) { Timber.i("Changing repeat setting to %d", repeat); mRepeat = repeat; switch (repeat) { case REPEAT_ALL: mMediaPlayer.enableRepeatAll(); break; case REPEAT_ONE: mMediaPlayer.enableRepeatOne(); break; case REPEAT_NONE: default: mMediaPlayer.enableRepeatNone(); } } /** * Sets the Multi-Repeat counter to repeat a song {@code count} times before proceeding to the * next song * @param count The number of times to repeat the song. When multi-repeat is enabled, the * current song will be played back-to-back for the specified number of loops. * Once this counter decrements to 0, playback will resume as it was before and the * previous repeat option will be restored unless it was previously Repeat All. If * Repeat All was enabled before Multi-Repeat, the repeat setting will be reset to * Repeat none. */ public void setMultiRepeat(int count) { Timber.i("Changing Multi-Repeat counter to %d", count); mMultiRepeat = count; mRemotePreferenceStore.setMultiRepeatCount(count); if (count > 1) { mMediaPlayer.enableRepeatOne(); } else { setRepeat(mRepeat); } } /** * Gets the current Multi-Repeat status * @return The number of times that the current song will be played back-to-back. This is * decremented when the song finishes. If Multi-Repeat is disabled, this method * will return {@code 0}. */ public int getMultiRepeatCount() { return mMultiRepeat; } /** * Enables or updates the sleep timer to pause music at a specified timestamp * @param endTimestampInMillis The timestamp to pause music. This is in milliseconds since the * Unix epoch as returned by {@link System#currentTimeMillis()}. */ public void setSleepTimer(long endTimestampInMillis) { Timber.i("Changing sleep timer end time to %d", endTimestampInMillis); startSleepTimer(endTimestampInMillis); mRemotePreferenceStore.setSleepTimerEndTime(endTimestampInMillis); } /** * Internal method for setting up the system timer to pause music. * @param endTimestampInMillis The timestamp to pause music in milliseconds since the Unix epoch * @see #setSleepTimer(long) */ private void startSleepTimer(long endTimestampInMillis) { if (endTimestampInMillis <= System.currentTimeMillis()) { Timber.i("Sleep timer end time (%1$d) is in the past (currently %2$d). Stopping timer", endTimestampInMillis, System.currentTimeMillis()); mHandler.removeCallbacks(mSleepTimerRunnable); } else { long delay = endTimestampInMillis - System.currentTimeMillis(); Timber.i("Setting sleep timer for %d ms", delay); mHandler.postDelayed(mSleepTimerRunnable, delay); } } /** * Internal method called once the sleep timer is triggered. * @see #setSleepTimer(long) to set the sleep timer * @see #startSleepTimer(long) for the sleep timer setup */ private void onSleepTimerEnd() { Timber.i("Sleep timer ended."); pause(); updateUi(); postInfo(mContext.getString(R.string.confirm_sleep_timer_end)); } /** * Gets the current end time of the sleep timer. * @return The current end time of the sleep timer in milliseconds since the Unix epoch. This * method returns {@code 0} if the sleep timer is disabled. */ public long getSleepTimerEndTime() { return mRemotePreferenceStore.getSleepTimerEndTime(); } /** * Sets the shuffle option and immediately applies it to the queue * @param shuffle The new shuffle option. {@code true} will switch the current playback to a * copy of the current queue in a randomized order. {@code false} will restore * the queue to its original order. */ public void setShuffle(boolean shuffle) { if (shuffle) { Timber.i("Enabling shuffle..."); shuffleQueue(getQueuePosition()); mMediaPlayer.setQueue(mQueueShuffled, 0); } else { Timber.i("Disabling shuffle..."); unshuffleQueue(); int position = mQueue.indexOf(getNowPlaying()); mMediaPlayer.setQueue(mQueue, position); } mShuffle = shuffle; updateNowPlaying(); } /** * Adds a {@link Song} to the queue to be played after the current song * @param song the song to enqueue */ public void queueNext(Song song) { Timber.i("queueNext(Song) called"); int index = mQueue.isEmpty() ? 0 : mMediaPlayer.getQueueIndex() + 1; List<Song> shuffledQueue = new ArrayList<>(mQueueShuffled); List<Song> queue = new ArrayList<>(mQueue); if (mShuffle) { shuffledQueue.add(index, song); queue.add(song); } else { queue.add(index, song); } mQueueShuffled = Collections.unmodifiableList(shuffledQueue); mQueue = Collections.unmodifiableList(queue); setBackingQueue(); } /** * Adds a {@link List} of {@link Song}s to the queue to be played after the current song * @param songs The songs to enqueue */ public void queueNext(List<Song> songs) { Timber.i("queueNext(List<Song>) called"); int index = mQueue.isEmpty() ? 0 : mMediaPlayer.getQueueIndex() + 1; List<Song> shuffledQueue = new ArrayList<>(mQueueShuffled); List<Song> queue = new ArrayList<>(mQueue); if (mShuffle) { shuffledQueue.addAll(index, songs); queue.addAll(songs); } else { queue.addAll(index, songs); } mQueueShuffled = Collections.unmodifiableList(shuffledQueue); mQueue = Collections.unmodifiableList(queue); setBackingQueue(); } /** * Adds a {@link Song} to the end of the queue * @param song The song to enqueue */ public void queueLast(Song song) { Timber.i("queueLast(Song) called"); List<Song> shuffledQueue = new ArrayList<>(mQueueShuffled); List<Song> queue = new ArrayList<>(mQueue); if (mShuffle) { shuffledQueue.add(song); queue.add(song); } else { queue.add(song); } mQueueShuffled = Collections.unmodifiableList(shuffledQueue); mQueue = Collections.unmodifiableList(queue); setBackingQueue(); } /** * Adds a {@link List} of {@link Song}s to the end of the queue * @param songs The songs to enqueue */ public void queueLast(List<Song> songs) { Timber.i("queueLast(List<Song>)"); List<Song> shuffledQueue = new ArrayList<>(mQueueShuffled); List<Song> queue = new ArrayList<>(mQueue); if (mShuffle) { shuffledQueue.addAll(songs); queue.addAll(songs); } else { queue.addAll(songs); } mQueueShuffled = Collections.unmodifiableList(shuffledQueue); mQueue = Collections.unmodifiableList(queue); setBackingQueue(); } /** * Releases all resources and bindings associated with this MusicPlayer. * Once this is called, this MusicPlayer can no longer be used. */ public void release() { Timber.i("release() called"); ((AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE)).abandonAudioFocus(this); mContext.unregisterReceiver(mHeadphoneListener); // Make sure to disable the sleep timer to purge any delayed runnables in the message queue startSleepTimer(0); mFocused = false; mCallback = null; mMediaPlayer.stop(); mMediaPlayer.release(); mMediaSession.release(); mMediaPlayer = null; mContext = null; } protected MediaSessionCompat getMediaSession() { return mMediaSession; } @Override public void onCompletion(Song completed) { Timber.i("onCompletion called"); logPlayCount(completed, false); if (mMultiRepeat > 1) { Timber.i("Multi-Repeat (%d) is enabled. Restarting current song and decrementing.", mMultiRepeat); setMultiRepeat(mMultiRepeat - 1); updateNowPlaying(); updateUi(); } else if (mMediaPlayer.isComplete()) { updateNowPlaying(); updateUi(); } } @Override public void onSongStart() { Timber.i("Started new song"); mArtwork = Util.fetchFullArt(mContext, getNowPlaying()); updateNowPlaying(); updateUi(); } @Override public boolean onError(Throwable error) { Timber.i(error, "Sending error message to UI..."); postError(mContext.getString( R.string.message_play_error_io_exception, getNowPlaying().getSongName())); if (mQueue.size() > 1) { skip(); } else { stop(); } return true; } /** * Creates a snapshot of the current player state including the state of the queue, * seek position, playing status, etc. This is useful for undoing modifications to the state. * @return A {@link PlayerState} object with the current status of this MusicPlayer instance. * @see #restorePlayerState(PlayerState) To restore this state */ public PlayerState getState() { return new PlayerState.Builder() .setPlaying(isPlaying()) .setQueuePosition(getQueuePosition()) .setQueue(mQueue) .setShuffledQueue(mQueueShuffled) .setSeekPosition(getCurrentPosition()) .build(); } /** * Restores a player state created from {@link #getState()}. * @param state The state to be restored. All properties including seek position and playing * status will immediately be applied. */ public void restorePlayerState(PlayerState state) { mQueue = Collections.unmodifiableList(state.getQueue()); mQueueShuffled = Collections.unmodifiableList(state.getShuffledQueue()); setBackingQueue(state.getQueuePosition()); seekTo(state.getSeekPosition()); if (state.isPlaying()) { play(); } else { pause(); } updateNowPlaying(); updateUi(); } /** * A callback for receiving information about song changes -- useful for integrating * {@link MusicPlayer} with other components */ public interface OnPlaybackChangeListener { /** * Called when a MusicPlayer changes songs. This method will always be called, even if the * event was caused by an external source. Implement and attach this callback to provide * more integration with external sources which requires up-to-date song information * (i.e. to post a notification) * * This method will only be called after the current song changes -- not when the * {@link MediaPlayer} changes states. */ void onPlaybackChange(); } private static class MediaSessionCallback extends MediaSessionCompat.Callback { /** * A period of time added after a remote button press to delay handling the event. This * delay allows the user to press the remote button multiple times to execute different * actions */ private static final int REMOTE_CLICK_SLEEP_TIME_MS = 300; private int mClickCount; private MusicPlayer mMusicPlayer; private Handler mHandler; MediaSessionCallback(MusicPlayer musicPlayer) { mHandler = new Handler(); mMusicPlayer = musicPlayer; } private final Runnable mButtonHandler = () -> { if (mClickCount == 1) { mMusicPlayer.togglePlay(); mMusicPlayer.updateUi(); } else if (mClickCount == 2) { onSkipToNext(); } else { onSkipToPrevious(); } mClickCount = 0; }; @Override public boolean onMediaButtonEvent(Intent mediaButtonEvent) { KeyEvent keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_HEADSETHOOK) { if (keyEvent.getAction() == KeyEvent.ACTION_UP && !keyEvent.isLongPress()) { onRemoteClick(); } return true; } else { return super.onMediaButtonEvent(mediaButtonEvent); } } private void onRemoteClick() { mClickCount++; mHandler.removeCallbacks(mButtonHandler); mHandler.postDelayed(mButtonHandler, REMOTE_CLICK_SLEEP_TIME_MS); } @Override public void onPlay() { mMusicPlayer.play(); mMusicPlayer.updateUi(); } @Override public void onSkipToQueueItem(long id) { mMusicPlayer.changeSong((int) id); mMusicPlayer.updateUi(); } @Override public void onPause() { mMusicPlayer.pause(); mMusicPlayer.updateUi(); } @Override public void onSkipToNext() { mMusicPlayer.skip(); mMusicPlayer.updateUi(); } @Override public void onSkipToPrevious() { mMusicPlayer.skipPrevious(); mMusicPlayer.updateUi(); } @Override public void onStop() { mMusicPlayer.stop(); // Don't update the UI if this object has been released if (mMusicPlayer.mContext != null) { mMusicPlayer.updateUi(); } } @Override public void onSeekTo(long pos) { mMusicPlayer.seekTo((int) pos); mMusicPlayer.updateUi(); } } /** * Receives headphone connect and disconnect intents so that music may be paused when headphones * are disconnected */ public static class HeadsetListener extends BroadcastReceiver { private MusicPlayer mInstance; public HeadsetListener(MusicPlayer instance) { mInstance = instance; } @Override public void onReceive(Context context, Intent intent) { if (isInitialStickyBroadcast()) { return; } Timber.i("onReceive: %s", intent); boolean plugged, unplugged; if (ACTION_HEADSET_PLUG.equals(intent.getAction())) { unplugged = intent.getIntExtra("state", -1) == 0; plugged = intent.getIntExtra("state", -1) == 1; } else { unplugged = plugged = false; } boolean becomingNoisy = ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction()); if (unplugged || becomingNoisy) { mInstance.pause(); mInstance.updateUi(); } else if (plugged && mInstance.shouldResumeOnHeadphonesConnect()) { mInstance.play(); mInstance.updateUi(); } } } }