package org.music.player; import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.MediaStore; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.ListIterator; import org.music.player.R; import junit.framework.Assert; /** * Contains the list of currently playing songs, implements repeat and shuffle * support, and contains methods to fetch more songs from the MediaStore. */ public final class SongTimeline { /** * Stop playback. * * @see SongTimeline#setFinishAction(int) */ public static final int FINISH_STOP = 0; /** * Repeat from the beginning. * * @see SongTimeline#setFinishAction(int) */ public static final int FINISH_REPEAT = 1; /** * Repeat the current song. This behavior is implemented entirely in * {@link PlaybackService#onCompletion(android.media.MediaPlayer)}; * pressing the next or previous buttons will advance the song as normal; * only allowing the song to play until the end will repeat it. * * @see SongTimeline#setFinishAction(int) */ public static final int FINISH_REPEAT_CURRENT = 2; /** * Stop playback after current song. This behavior is implemented entirely * in {@link PlaybackService#onCompletion(android.media.MediaPlayer)}; * pressing the next or previous buttons will advance the song as normal; * only allowing the song to play until the end. * * @see SongTimeline#setFinishAction(int) */ public static final int FINISH_STOP_CURRENT = 3; /** * Add random songs to the playlist. * * @see SongTimeline#setFinishAction(int) */ public static final int FINISH_RANDOM = 4; /** * Icons corresponding to each of the finish actions. */ public static final int[] FINISH_ICONS = { R.drawable.repeat_inactive, R.drawable.repeat_active, R.drawable.repeat_current_active, R.drawable.stop_current_active, R.drawable.random_active }; /** * Clear the timeline and use only the provided songs. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_PLAY = 0; /** * Clear the queue and add the songs after the current song. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_PLAY_NEXT = 1; /** * Add the songs at the end of the timeline, clearing random songs. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_ENQUEUE = 2; /** * Like play mode, but make the song at the given position play first by * removing the songs before the given position in the query and appending * them to the end of the queue. * * Pass the position in QueryTask.data. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_PLAY_POS_FIRST = 3; /** * Like play mode, but make the song with the given id play first by * removing the songs before the song in the query and appending * them to the end of the queue. If there are multiple songs with * the given id, picks the first song with that id. * * Pass the id in QueryTask.data. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_PLAY_ID_FIRST = 4; /** * Like enqueue mode, but make the song with the given id play first by * removing the songs before the song in the query and appending * them to the end of the queue. If there are multiple songs with * the given id, picks the first song with that id. * * Pass the id in QueryTask.data. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_ENQUEUE_ID_FIRST = 5; /** * Like enqueue mode, but make the song at the given position play first by * removing the songs before the given position in the query and appending * them to the end of the queue. * * Pass the position in QueryTask.data. * * @see SongTimeline#addSongs(Context, QueryTask) */ public static final int MODE_ENQUEUE_POS_FIRST = 6; /** * Disable shuffle. * * @see SongTimeline#setShuffleMode(int) */ public static final int SHUFFLE_NONE = 0; /** * Randomize order of songs. * * @see SongTimeline#setShuffleMode(int) */ public static final int SHUFFLE_SONGS = 1; /** * Randomize order of albums, preserving the order of tracks inside the * albums. * * @see SongTimeline#setShuffleMode(int) */ public static final int SHUFFLE_ALBUMS = 2; /** * Icons corresponding to each of the shuffle actions. */ public static final int[] SHUFFLE_ICONS = { R.drawable.shuffle_inactive, R.drawable.shuffle_active, R.drawable.shuffle_album_active }; /** * Move current position to the previous album. * * @see SongTimeline#shiftCurrentSong(int) */ public static final int SHIFT_PREVIOUS_ALBUM = -2; /** * Move current position to the previous song. * * @see SongTimeline#shiftCurrentSong(int) */ public static final int SHIFT_PREVIOUS_SONG = -1; /** * Move current position to the next song. * * @see SongTimeline#shiftCurrentSong(int) */ public static final int SHIFT_NEXT_SONG = 1; /** * Move current position to the next album. * * @see SongTimeline#shiftCurrentSong(int) */ public static final int SHIFT_NEXT_ALBUM = 2; private final Context mContext; /** * All the songs currently contained in the timeline. Each Song object * should be unique, even if it refers to the same media. */ private ArrayList<Song> mSongs = new ArrayList<Song>(12); /** * The position of the current song (i.e. the playing song). */ private int mCurrentPos; /** * How to shuffle/whether to shuffle. One of SongTimeline.SHUFFLE_*. */ private int mShuffleMode; /** * What to do when the end of the playlist is reached. * Must be one of SongTimeline.FINISH_*. */ private int mFinishAction; // for shuffleAll() private ArrayList<Song> mShuffledSongs; // for saveActiveSongs() private Song mSavedPrevious; private Song mSavedCurrent; private Song mSavedNext; private int mSavedPos; private int mSavedSize; /** * Interface to respond to timeline changes. */ public interface Callback { /** * Called when an active song in the timeline is replaced by a method * other than shiftCurrentSong() * * @param delta The distance from the current song. Will always be -1, * 0, or 1. * @param song The new song at the position */ public void activeSongReplaced(int delta, Song song); /** * Called when the timeline state has changed and should be saved to * storage. */ public void timelineChanged(); /** * Called when the length of the timeline has changed. */ public void positionInfoChanged(); } /** * The current Callback, if any. */ private Callback mCallback; public SongTimeline(Context context) { mContext = context; } /** * Compares the ids of songs. */ public static class IdComparator implements Comparator<Song> { @Override public int compare(Song a, Song b) { if (a.id == b.id) return 0; if (a.id > b.id) return 1; return -1; } } /** * Compares the flags of songs. */ public static class FlagComparator implements Comparator<Song> { @Override public int compare(Song a, Song b) { return a.flags - b.flags; } } /** * Initializes the timeline with data read from the stream. Data should have * been saved by a call to {@link SongTimeline#writeState(DataOutputStream)}. * * @param in The stream to read from. */ public void readState(DataInputStream in) throws IOException { synchronized (this) { int n = in.readInt(); if (n > 0) { ArrayList<Song> songs = new ArrayList<Song>(n); // Fill the selection with the ids of all the saved songs // and initialize the timeline with unpopulated songs. StringBuilder selection = new StringBuilder("_ID IN ("); for (int i = 0; i != n; ++i) { long id = in.readLong(); if (id == -1) continue; // Add the index to the flags so we can sort int flags = in.readInt() & ~(~0 << Song.FLAG_COUNT) | i << Song.FLAG_COUNT; songs.add(new Song(id, flags)); if (i != 0) selection.append(','); selection.append(id); } selection.append(')'); // Sort songs by id---this is the order the query will // return its results in. Collections.sort(songs, new IdComparator()); ContentResolver resolver = mContext.getContentResolver(); Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; Cursor cursor = resolver.query(media, Song.FILLED_PROJECTION, selection.toString(), null, "_id"); if (cursor != null) { if (cursor.getCount() != 0) { cursor.moveToNext(); // Loop through timeline entries, looking for a row // that matches the id. One row may match multiple // entries. Iterator<Song> it = songs.iterator(); while (it.hasNext()) { Song e = it.next(); while (cursor.getLong(0) < e.id && !cursor.isLast()) cursor.moveToNext(); if (cursor.getLong(0) == e.id) e.populate(cursor); else // We weren't able to query this song. it.remove(); } } cursor.close(); // Revert to the order the songs were saved in. Collections.sort(songs, new FlagComparator()); mSongs = songs; } } mCurrentPos = Math.min(mSongs == null ? 0 : mSongs.size(), in.readInt()); mFinishAction = in.readInt(); mShuffleMode = in.readInt(); } } /** * Writes the current songs and state to the given stream. * * @param out The stream to write to. */ public void writeState(DataOutputStream out) throws IOException { // Must update PlaybackService.STATE_VERSION when changing behavior // here. synchronized (this) { ArrayList<Song> songs = mSongs; int size = songs.size(); out.writeInt(size); for (int i = 0; i != size; ++i) { Song song = songs.get(i); if (song == null) { out.writeLong(-1); } else { out.writeLong(song.id); out.writeInt(song.flags); } } out.writeInt(mCurrentPos); out.writeInt(mFinishAction); out.writeInt(mShuffleMode); } } /** * Sets the current callback to <code>callback</code>. */ public void setCallback(Callback callback) { mCallback = callback; } /** * Return the current shuffle mode. * * @return The shuffle mode. One of SongTimeline.SHUFFLE_*. */ public int getShuffleMode() { return mShuffleMode; } /** * Return the finish action. * * @see SongTimeline#setFinishAction(int) */ public int getFinishAction() { return mFinishAction; } /** * Set how to shuffle. Will shuffle the current set of songs when enabling * shuffling if random mode is not enabled. * * @param mode One of SongTimeline.MODE_* */ public void setShuffleMode(int mode) { if (mode == mShuffleMode) return; synchronized (this) { saveActiveSongs(); mShuffledSongs = null; mShuffleMode = mode; if (mode != SHUFFLE_NONE && mFinishAction != FINISH_RANDOM && !mSongs.isEmpty()) { shuffleAll(); ArrayList<Song> songs = mShuffledSongs; mShuffledSongs = null; mCurrentPos = songs.indexOf(mSavedCurrent); mSongs = songs; } broadcastChangedSongs(); } changed(); } /** * Set what to do when the end of the playlist is reached. Must be one of * SongTimeline.FINISH_* (stop, repeat, or add random song). */ public void setFinishAction(int action) { saveActiveSongs(); mFinishAction = action; broadcastChangedSongs(); changed(); } /** * Shuffle all the songs in the timeline, storing the result in * mShuffledSongs. * * @return The first song from the shuffled songs. */ private Song shuffleAll() { if (mShuffledSongs != null) return mShuffledSongs.get(0); ArrayList<Song> songs = new ArrayList<Song>(mSongs); MediaUtils.shuffle(songs, mShuffleMode == SHUFFLE_ALBUMS); mShuffledSongs = songs; return songs.get(0); } /** * Returns the song <code>delta</code> places away from the current * position. Returns null if there is a problem retrieving the song. * * @param delta The offset from the current position. Must be -1, 0, or 1. */ public Song getSong(int delta) { Assert.assertTrue(delta >= -1 && delta <= 1); ArrayList<Song> timeline = mSongs; Song song; synchronized (this) { int pos = mCurrentPos + delta; int size = timeline.size(); if (pos < 0) { if (size == 0 || mFinishAction == FINISH_RANDOM) return null; song = timeline.get(Math.max(0, size - 1)); } else if (pos > size) { return null; } else if (pos == size) { if (mFinishAction == FINISH_RANDOM) { song = MediaUtils.randomSong(mContext.getContentResolver()); if (song == null) return null; timeline.add(song); } else { if (size == 0) // empty queue return null; else if (mShuffleMode != SHUFFLE_NONE) song = shuffleAll(); else song = timeline.get(0); } } else { song = timeline.get(pos); } } if (song == null) // we have no songs in the library return null; return song; } /** * Internal implementation for shiftCurrentSong. Does all the work except * broadcasting the timeline change: updates mCurrentPos and handles * shuffling, repeating, and random mode. * * @param delta -1 to move to the previous song or 1 for the next. */ private void shiftCurrentSongInternal(int delta) { int pos = mCurrentPos + delta; if (mFinishAction != FINISH_RANDOM && pos == mSongs.size()) { if (mShuffleMode != SHUFFLE_NONE && !mSongs.isEmpty()) { if (mShuffledSongs == null) shuffleAll(); mSongs = mShuffledSongs; } pos = 0; } else if (pos < 0) { if (mFinishAction == FINISH_RANDOM) pos = 0; else pos = Math.max(0, mSongs.size() - 1); } mCurrentPos = pos; mShuffledSongs = null; } /** * Move to the next or previous song or album. * * @param delta One of SongTimeline.SHIFT_*. * @return The Song at the new position */ public Song shiftCurrentSong(int delta) { synchronized (this) { if (delta == SHIFT_PREVIOUS_SONG || delta == SHIFT_NEXT_SONG) { shiftCurrentSongInternal(delta); } else { Song song = getSong(0); long currentAlbum = song.albumId; long currentSong = song.id; delta = delta > 0 ? 1 : -1; do { shiftCurrentSongInternal(delta); song = getSong(0); } while (currentAlbum == song.albumId && currentSong != song.id); } } changed(); return getSong(0); } /** * Run the given query and add the results to the song timeline. * * @param context A context to use. * @param query The query to be run. The mode variable must be initialized * to one of SongTimeline.MODE_*. The type and data variables may also need * to be initialized depending on the given mode. * @return The number of songs that were added. */ public int addSongs(Context context, QueryTask query) { Cursor cursor = query.runQuery(context.getContentResolver()); if (cursor == null) { return 0; } int count = cursor.getCount(); if (count == 0) { return 0; } int mode = query.mode; int type = query.type; long data = query.data; ArrayList<Song> timeline = mSongs; synchronized (this) { saveActiveSongs(); switch (mode) { case MODE_ENQUEUE: case MODE_ENQUEUE_POS_FIRST: case MODE_ENQUEUE_ID_FIRST: if (mFinishAction == FINISH_RANDOM) { int j = timeline.size(); while (--j > mCurrentPos) { if (timeline.get(j).isRandom()) timeline.remove(j); } } break; case MODE_PLAY_NEXT: timeline.subList(mCurrentPos + 1, timeline.size()).clear(); break; case MODE_PLAY: case MODE_PLAY_POS_FIRST: case MODE_PLAY_ID_FIRST: timeline.clear(); mCurrentPos = 0; break; default: throw new IllegalArgumentException("Invalid mode: " + mode); } int start = timeline.size(); Song jumpSong = null; for (int j = 0; j != count; ++j) { cursor.moveToPosition(j); Song song = new Song(-1); song.populate(cursor); timeline.add(song); if (jumpSong == null) { if ((mode == MODE_PLAY_POS_FIRST || mode == MODE_ENQUEUE_POS_FIRST) && j == data) { jumpSong = song; } else if (mode == MODE_PLAY_ID_FIRST || mode == MODE_ENQUEUE_ID_FIRST) { long id; switch (type) { case MediaUtils.TYPE_ARTIST: id = song.artistId; break; case MediaUtils.TYPE_ALBUM: id = song.albumId; break; case MediaUtils.TYPE_SONG: id = song.id; break; default: throw new IllegalArgumentException("Unsupported id type: " + type); } if (id == data) jumpSong = song; } } } if (mShuffleMode != SHUFFLE_NONE) MediaUtils.shuffle(timeline.subList(start, timeline.size()), mShuffleMode == SHUFFLE_ALBUMS); if (jumpSong != null) { int jumpPos = timeline.indexOf(jumpSong); if (jumpPos != start) { // Get the sublist twice to avoid a ConcurrentModificationException. timeline.addAll(timeline.subList(start, jumpPos)); timeline.subList(start, jumpPos).clear(); } } broadcastChangedSongs(); } changed(); return count; } /** * Removes any songs greater than 10 songs before the current song when in * random mode. */ public void purge() { synchronized (this) { if (mFinishAction == FINISH_RANDOM) { while (mCurrentPos > 10) { mSongs.remove(0); --mCurrentPos; } } } } /** * Clear the song queue. */ public void clearQueue() { synchronized (this) { if (mCurrentPos + 1 < mSongs.size()) mSongs.subList(mCurrentPos + 1, mSongs.size()).clear(); } mCallback.activeSongReplaced(+1, getSong(+1)); mCallback.positionInfoChanged(); changed(); } /** * Save the active songs for use with broadcastChangedSongs(). * * @see SongTimeline#broadcastChangedSongs() */ private void saveActiveSongs() { mSavedPrevious = getSong(-1); mSavedCurrent = getSong(0); mSavedNext = getSong(+1); mSavedPos = mCurrentPos; mSavedSize = mSongs.size(); } /** * Broadcast the active songs that have changed since the last call to * saveActiveSongs() * * @see SongTimeline#saveActiveSongs() */ private void broadcastChangedSongs() { Song previous = getSong(-1); Song current = getSong(0); Song next = getSong(+1); if (Song.getId(mSavedPrevious) != Song.getId(previous)) mCallback.activeSongReplaced(-1, previous); if (Song.getId(mSavedNext) != Song.getId(next)) mCallback.activeSongReplaced(1, next); if (Song.getId(mSavedCurrent) != Song.getId(current)) mCallback.activeSongReplaced(0, current); if (mCurrentPos != mSavedPos || mSongs.size() != mSavedSize) mCallback.positionInfoChanged(); } /** * Remove the song with the given id from the timeline. * * @param id The MediaStore id of the song to remove. */ public void removeSong(long id) { synchronized (this) { saveActiveSongs(); ArrayList<Song> songs = mSongs; ListIterator<Song> it = songs.listIterator(); while (it.hasNext()) { int i = it.nextIndex(); if (Song.getId(it.next()) == id) { if (i < mCurrentPos) --mCurrentPos; it.remove(); } } broadcastChangedSongs(); } changed(); } /** * Broadcasts that the timeline state has changed. */ private void changed() { if (mCallback != null) mCallback.timelineChanged(); } /** * Return true if the finish action is to stop at the end of the queue and * the current song is the last in the queue. */ public boolean isEndOfQueue() { synchronized (this) { return mFinishAction == FINISH_STOP && mCurrentPos == mSongs.size() - 1; } } /** * Returns the position of the current song in the timeline. */ public int getPosition() { return mCurrentPos; } /** * Returns the current number of songs in the timeline. */ public int getLength() { return mSongs.size(); } }