/* * 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. * * <p>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("<unknown>")) { builder.append(mUnknownAlbum); } else { builder.append(name); } builder.append('\n'); name = cursor.getString(mArtistIdx); if (name == null || name.equals("<unknown>")) { 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; } } }