/*
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.music;
import android.app.ListActivity;
import android.content.AsyncQueryHandler;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.database.CharArrayBuffer;
import android.database.Cursor;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.animation.AnimationUtils;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.RadioButton;
import android.widget.SectionIndexer;
import android.widget.SimpleCursorAdapter;
import android.widget.TextView;
import java.io.IOException;
import java.text.Collator;
import java.util.Formatter;
import java.util.Locale;
/**
* Activity allowing the user to select a music track on the device, and
* return it to its caller. The music picker user interface is fairly
* extensive, providing information about each track like the music
* application (title, author, album, duration), as well as the ability to
* previous tracks and sort them in different orders.
*
*
This class also illustrates how you can load data from a content
* provider asynchronously, providing a good UI while doing so, perform
* indexing of the content for use inside of a {@link FastScrollView}, and
* perform filtering of the data as the user presses keys.
*/
public class MusicPicker extends ListActivity
implements View.OnClickListener, MediaPlayer.OnCompletionListener, MusicUtils.Defs {
static final boolean DBG = false;
static final String TAG = "MusicPicker";
/** Holds the previous state of the list, to restore after the async
* query has completed. */
static final String LIST_STATE_KEY = "liststate";
/** Remember whether the list last had focus for restoring its state. */
static final String FOCUS_KEY = "focused";
/** Remember the last ordering mode for restoring state. */
static final String SORT_MODE_KEY = "sortMode";
/** Arbitrary number, doesn't matter since we only do one query type. */
static final int MY_QUERY_TOKEN = 42;
/** Menu item to sort the music list by track title. */
static final int TRACK_MENU = Menu.FIRST;
/** Menu item to sort the music list by album title. */
static final int ALBUM_MENU = Menu.FIRST + 1;
/** Menu item to sort the music list by artist name. */
static final int ARTIST_MENU = Menu.FIRST + 2;
/** These are the columns in the music cursor that we are interested in. */
static final String[] CURSOR_COLS = new String[] {MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.TITLE_KEY,
MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST_ID,
MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.TRACK};
/** Formatting optimization to avoid creating many temporary objects. */
static StringBuilder sFormatBuilder = new StringBuilder();
/** Formatting optimization to avoid creating many temporary objects. */
static Formatter sFormatter = new Formatter(sFormatBuilder, Locale.getDefault());
/** Formatting optimization to avoid creating many temporary objects. */
static final Object[] sTimeArgs = new Object[5];
/** Uri to the directory of all music being displayed. */
Uri mBaseUri;
/** This is the adapter used to display all of the tracks. */
TrackListAdapter mAdapter;
/** Our instance of QueryHandler used to perform async background queries. */
QueryHandler mQueryHandler;
/** Used to keep track of the last scroll state of the list. */
Parcelable mListState = null;
/** Used to keep track of whether the list last had focus. */
boolean mListHasFocus;
/** The current cursor on the music that is being displayed. */
Cursor mCursor;
/** The actual sort order the user has selected. */
int mSortMode = -1;
/** SQL order by string describing the currently selected sort order. */
String mSortOrder;
/** Container of the in-screen progress indicator, to be able to hide it
* when done loading the initial cursor. */
View mProgressContainer;
/** Container of the list view hierarchy, to be able to show it when done
* loading the initial cursor. */
View mListContainer;
/** Set to true when the list view has been shown for the first time. */
boolean mListShown;
/** View holding the okay button. */
View mOkayButton;
/** View holding the cancel button. */
View mCancelButton;
/** Which track row ID the user has last selected. */
long mSelectedId = -1;
/** Completel Uri that the user has last selected. */
Uri mSelectedUri;
/** If >= 0, we are currently playing a track for preview, and this is its
* row ID. */
long mPlayingId = -1;
/** This is used for playing previews of the music files. */
MediaPlayer mMediaPlayer;
/**
* A special implementation of SimpleCursorAdapter that knows how to bind
* our cursor data to our list item structure, and takes care of other
* advanced features such as indexing and filtering.
*/
class TrackListAdapter extends SimpleCursorAdapter implements SectionIndexer {
final ListView mListView;
private final StringBuilder mBuilder = new StringBuilder();
private final String mUnknownArtist;
private final String mUnknownAlbum;
private int mIdIdx;
private int mTitleIdx;
private int mArtistIdx;
private int mAlbumIdx;
private int mDurationIdx;
private boolean mLoading = true;
private int mIndexerSortMode;
private MusicAlphabetIndexer mIndexer;
class ViewHolder {
TextView line1;
TextView line2;
TextView duration;
RadioButton radio;
ImageView play_indicator;
CharArrayBuffer buffer1;
char[] buffer2;
}
TrackListAdapter(Context context, ListView listView, int layout, String[] from, int[] to) {
super(context, layout, null, from, to);
mListView = listView;
mUnknownArtist = context.getString(R.string.unknown_artist_name);
mUnknownAlbum = context.getString(R.string.unknown_album_name);
}
/**
* The mLoading flag is set while we are performing a background
* query, to avoid displaying the "No music" empty view during
* this time.
*/
public void setLoading(boolean loading) {
mLoading = loading;
}
@Override
public boolean isEmpty() {
if (mLoading) {
// We don't want the empty state to show when loading.
return false;
} else {
return super.isEmpty();
}
}
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
View v = super.newView(context, cursor, parent);
ViewHolder vh = new ViewHolder();
vh.line1 = (TextView) v.findViewById(R.id.line1);
vh.line2 = (TextView) v.findViewById(R.id.line2);
vh.duration = (TextView) v.findViewById(R.id.duration);
vh.radio = (RadioButton) v.findViewById(R.id.radio);
vh.play_indicator = (ImageView) v.findViewById(R.id.play_indicator);
vh.buffer1 = new CharArrayBuffer(100);
vh.buffer2 = new char[200];
v.setTag(vh);
return v;
}
@Override
public void bindView(View view, Context context, Cursor cursor) {
ViewHolder vh = (ViewHolder) view.getTag();
cursor.copyStringToBuffer(mTitleIdx, vh.buffer1);
vh.line1.setText(vh.buffer1.data, 0, vh.buffer1.sizeCopied);
int secs = cursor.getInt(mDurationIdx) / 1000;
if (secs == 0) {
vh.duration.setText("");
} else {
vh.duration.setText(MusicUtils.makeTimeString(context, secs));
}
final StringBuilder builder = mBuilder;
builder.delete(0, builder.length());
String name = cursor.getString(mAlbumIdx);
if (name == null || name.equals("")) {
builder.append(mUnknownAlbum);
} else {
builder.append(name);
}
builder.append('\n');
name = cursor.getString(mArtistIdx);
if (name == null || name.equals("")) {
builder.append(mUnknownArtist);
} else {
builder.append(name);
}
int len = builder.length();
if (vh.buffer2.length < len) {
vh.buffer2 = new char[len];
}
builder.getChars(0, len, vh.buffer2, 0);
vh.line2.setText(vh.buffer2, 0, len);
// Update the checkbox of the item, based on which the user last
// selected. Note that doing it this way means we must have the
// list view update all of its items when the selected item
// changes.
final long id = cursor.getLong(mIdIdx);
vh.radio.setChecked(id == mSelectedId);
if (DBG)
Log.v(TAG, "Binding id=" + id + " sel=" + mSelectedId + " playing=" + mPlayingId
+ " cursor=" + cursor);
// Likewise, display the "now playing" icon if this item is
// currently being previewed for the user.
ImageView iv = vh.play_indicator;
if (id == mPlayingId) {
iv.setImageResource(R.drawable.indicator_ic_mp_playing_list);
iv.setVisibility(View.VISIBLE);
} else {
iv.setVisibility(View.GONE);
}
}
/**
* This method is called whenever we receive a new cursor due to
* an async query, and must take care of plugging the new one in
* to the adapter.
*/
@Override
public void changeCursor(Cursor cursor) {
super.changeCursor(cursor);
if (DBG)
Log.v(TAG, "Setting cursor to: " + cursor + " from: " + MusicPicker.this.mCursor);
MusicPicker.this.mCursor = cursor;
if (cursor != null) {
// Retrieve indices of the various columns we are interested in.
mIdIdx = cursor.getColumnIndex(MediaStore.Audio.Media._ID);
mTitleIdx = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE);
mArtistIdx = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST);
mAlbumIdx = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM);
mDurationIdx = cursor.getColumnIndex(MediaStore.Audio.Media.DURATION);
// If the sort mode has changed, or we haven't yet created an
// indexer one, then create a new one that is indexing the
// appropriate column based on the sort mode.
if (mIndexerSortMode != mSortMode || mIndexer == null) {
mIndexerSortMode = mSortMode;
int idx = mTitleIdx;
switch (mIndexerSortMode) {
case ARTIST_MENU:
idx = mArtistIdx;
break;
case ALBUM_MENU:
idx = mAlbumIdx;
break;
}
mIndexer = new MusicAlphabetIndexer(
cursor, idx, getResources().getString(R.string.fast_scroll_alphabet));
// If we have a valid indexer, but the cursor has changed since
// its last use, then point it to the current cursor.
} else {
mIndexer.setCursor(cursor);
}
}
// Ensure that the list is shown (and initial progress indicator
// hidden) in case this is the first cursor we have gotten.
makeListShown();
}
/**
* This method is called from a background thread by the list view
* when the user has typed a letter that should result in a filtering
* of the displayed items. It returns a Cursor, when will then be
* handed to changeCursor.
*/
@Override
public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
if (DBG) Log.v(TAG, "Getting new cursor...");
return doQuery(true, constraint.toString());
}
public int getPositionForSection(int section) {
Cursor cursor = getCursor();
if (cursor == null) {
// No cursor, the section doesn't exist so just return 0
return 0;
}
return mIndexer.getPositionForSection(section);
}
public int getSectionForPosition(int position) {
return 0;
}
public Object[] getSections() {
if (mIndexer != null) {
return mIndexer.getSections();
}
return null;
}
}
/**
* This is our specialization of AsyncQueryHandler applies new cursors
* to our state as they become available.
*/
private final class QueryHandler extends AsyncQueryHandler {
public QueryHandler(Context context) {
super(context.getContentResolver());
}
@Override
protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
if (!isFinishing()) {
// Update the adapter: we are no longer loading, and have
// a new cursor for it.
mAdapter.setLoading(false);
mAdapter.changeCursor(cursor);
setProgressBarIndeterminateVisibility(false);
// Now that the cursor is populated again, it's possible to restore the list state
if (mListState != null) {
getListView().onRestoreInstanceState(mListState);
if (mListHasFocus) {
getListView().requestFocus();
}
mListHasFocus = false;
mListState = null;
}
} else {
cursor.close();
}
}
}
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
int sortMode = TRACK_MENU;
if (icicle == null) {
mSelectedUri =
getIntent().getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI);
} else {
mSelectedUri = (Uri) icicle.getParcelable(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI);
// Retrieve list state. This will be applied after the
// QueryHandler has run
mListState = icicle.getParcelable(LIST_STATE_KEY);
mListHasFocus = icicle.getBoolean(FOCUS_KEY);
sortMode = icicle.getInt(SORT_MODE_KEY, sortMode);
}
if (Intent.ACTION_GET_CONTENT.equals(getIntent().getAction())) {
mBaseUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
} else {
mBaseUri = getIntent().getData();
if (mBaseUri == null) {
Log.w("MusicPicker", "No data URI given to PICK action");
finish();
return;
}
}
setContentView(R.layout.music_picker);
mSortOrder = MediaStore.Audio.Media.TITLE_KEY;
final ListView listView = getListView();
listView.setItemsCanFocus(false);
mAdapter = new TrackListAdapter(
this, listView, R.layout.music_picker_item, new String[] {}, new int[] {});
setListAdapter(mAdapter);
listView.setTextFilterEnabled(true);
// We manually save/restore the listview state
listView.setSaveEnabled(false);
mQueryHandler = new QueryHandler(this);
mProgressContainer = findViewById(R.id.progressContainer);
mListContainer = findViewById(R.id.listContainer);
mOkayButton = findViewById(R.id.okayButton);
mOkayButton.setOnClickListener(this);
mCancelButton = findViewById(R.id.cancelButton);
mCancelButton.setOnClickListener(this);
// If there is a currently selected Uri, then try to determine who
// it is.
if (mSelectedUri != null) {
Uri.Builder builder = mSelectedUri.buildUpon();
String path = mSelectedUri.getEncodedPath();
int idx = path.lastIndexOf('/');
if (idx >= 0) {
path = path.substring(0, idx);
}
builder.encodedPath(path);
Uri baseSelectedUri = builder.build();
if (DBG) Log.v(TAG, "Selected Uri: " + mSelectedUri);
if (DBG) Log.v(TAG, "Selected base Uri: " + baseSelectedUri);
if (DBG) Log.v(TAG, "Base Uri: " + mBaseUri);
if (baseSelectedUri.equals(mBaseUri)) {
// If the base Uri of the selected Uri is the same as our
// content's base Uri, then use the selection!
mSelectedId = ContentUris.parseId(mSelectedUri);
}
}
setSortMode(sortMode);
}
@Override
public void onRestart() {
super.onRestart();
doQuery(false, null);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (setSortMode(item.getItemId())) {
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
menu.add(Menu.NONE, TRACK_MENU, Menu.NONE, R.string.sort_by_track);
menu.add(Menu.NONE, ALBUM_MENU, Menu.NONE, R.string.sort_by_album);
menu.add(Menu.NONE, ARTIST_MENU, Menu.NONE, R.string.sort_by_artist);
return true;
}
@Override
protected void onSaveInstanceState(Bundle icicle) {
super.onSaveInstanceState(icicle);
// Save list state in the bundle so we can restore it after the
// QueryHandler has run
icicle.putParcelable(LIST_STATE_KEY, getListView().onSaveInstanceState());
icicle.putBoolean(FOCUS_KEY, getListView().hasFocus());
icicle.putInt(SORT_MODE_KEY, mSortMode);
}
@Override
public void onPause() {
super.onPause();
stopMediaPlayer();
}
@Override
public void onStop() {
super.onStop();
// We don't want the list to display the empty state, since when we
// resume it will still be there and show up while the new query is
// happening. After the async query finishes in response to onResume()
// setLoading(false) will be called.
mAdapter.setLoading(true);
mAdapter.changeCursor(null);
}
/**
* Changes the current sort order, building the appropriate query string
* for the selected order.
*/
boolean setSortMode(int sortMode) {
if (sortMode != mSortMode) {
switch (sortMode) {
case TRACK_MENU:
mSortMode = sortMode;
mSortOrder = MediaStore.Audio.Media.TITLE_KEY;
doQuery(false, null);
return true;
case ALBUM_MENU:
mSortMode = sortMode;
mSortOrder = MediaStore.Audio.Media.ALBUM_KEY + " ASC, "
+ MediaStore.Audio.Media.TRACK + " ASC, "
+ MediaStore.Audio.Media.TITLE_KEY + " ASC";
doQuery(false, null);
return true;
case ARTIST_MENU:
mSortMode = sortMode;
mSortOrder = MediaStore.Audio.Media.ARTIST_KEY + " ASC, "
+ MediaStore.Audio.Media.ALBUM_KEY + " ASC, "
+ MediaStore.Audio.Media.TRACK + " ASC, "
+ MediaStore.Audio.Media.TITLE_KEY + " ASC";
doQuery(false, null);
return true;
}
}
return false;
}
/**
* The first time this is called, we hide the large progress indicator
* and show the list view, doing fade animations between them.
*/
void makeListShown() {
if (!mListShown) {
mListShown = true;
mProgressContainer.startAnimation(
AnimationUtils.loadAnimation(this, android.R.anim.fade_out));
mProgressContainer.setVisibility(View.GONE);
mListContainer.startAnimation(
AnimationUtils.loadAnimation(this, android.R.anim.fade_in));
mListContainer.setVisibility(View.VISIBLE);
}
}
/**
* Common method for performing a query of the music database, called for
* both top-level queries and filtering.
*
* @param sync If true, this query should be done synchronously and the
* resulting cursor returned. If false, it will be done asynchronously and
* null returned.
* @param filterstring If non-null, this is a filter to apply to the query.
*/
Cursor doQuery(boolean sync, String filterstring) {
// Cancel any pending queries
mQueryHandler.cancelOperation(MY_QUERY_TOKEN);
StringBuilder where = new StringBuilder();
where.append(MediaStore.Audio.Media.TITLE + " != ''");
// We want to show all audio files, even recordings. Enforcing the
// following condition would hide recordings.
// where.append(" AND " + MediaStore.Audio.Media.IS_MUSIC + "=1");
Uri uri = mBaseUri;
if (!TextUtils.isEmpty(filterstring)) {
uri = uri.buildUpon().appendQueryParameter("filter", Uri.encode(filterstring)).build();
}
if (sync) {
try {
return getContentResolver().query(
uri, CURSOR_COLS, where.toString(), null, mSortOrder);
} catch (UnsupportedOperationException ex) {
}
} else {
mAdapter.setLoading(true);
setProgressBarIndeterminateVisibility(true);
mQueryHandler.startQuery(
MY_QUERY_TOKEN, null, uri, CURSOR_COLS, where.toString(), null, mSortOrder);
}
return null;
}
@Override
protected void onListItemClick(ListView l, View v, int position, long id) {
mCursor.moveToPosition(position);
if (DBG)
Log.v(TAG, "Click on " + position + " (id=" + id + ", cursid="
+ mCursor.getLong(mCursor.getColumnIndex(MediaStore.Audio.Media._ID))
+ ") in cursor " + mCursor + " adapter=" + l.getAdapter());
setSelected(mCursor);
}
void setSelected(Cursor c) {
Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
long newId = mCursor.getLong(mCursor.getColumnIndex(MediaStore.Audio.Media._ID));
mSelectedUri = ContentUris.withAppendedId(uri, newId);
mSelectedId = newId;
if (newId != mPlayingId || mMediaPlayer == null) {
stopMediaPlayer();
mMediaPlayer = new MediaPlayer();
try {
mMediaPlayer.setDataSource(this, mSelectedUri);
mMediaPlayer.setOnCompletionListener(this);
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_RING);
mMediaPlayer.prepare();
mMediaPlayer.start();
mPlayingId = newId;
getListView().invalidateViews();
} catch (IOException e) {
Log.w("MusicPicker", "Unable to play track", e);
}
} else if (mMediaPlayer != null) {
stopMediaPlayer();
getListView().invalidateViews();
}
}
public void onCompletion(MediaPlayer mp) {
if (mMediaPlayer == mp) {
mp.stop();
mp.release();
mMediaPlayer = null;
mPlayingId = -1;
getListView().invalidateViews();
}
}
void stopMediaPlayer() {
if (mMediaPlayer != null) {
mMediaPlayer.stop();
mMediaPlayer.release();
mMediaPlayer = null;
mPlayingId = -1;
}
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.okayButton:
if (mSelectedId >= 0) {
setResult(RESULT_OK, new Intent().setData(mSelectedUri));
finish();
}
break;
case R.id.cancelButton:
finish();
break;
}
}
}