diff options
| author | Rakesh Iyer <rni@google.com> | 2016-10-19 23:31:13 -0700 |
|---|---|---|
| committer | Rakesh Iyer <rni@google.com> | 2016-10-20 00:26:09 -0700 |
| commit | 7e37ae05a9c7c9ecf8569a5bfe752a54f660a6ba (patch) | |
| tree | ac653a86179441ec93fccc9047a0ffff7d4722b7 /src/com | |
| parent | 468bbbd104cd450b404fece9ff94628b1953e293 (diff) | |
| download | platform_packages_apps_Car_LocalMediaPlayer-7e37ae05a9c7c9ecf8569a5bfe752a54f660a6ba.tar.gz platform_packages_apps_Car_LocalMediaPlayer-7e37ae05a9c7c9ecf8569a5bfe752a54f660a6ba.tar.bz2 platform_packages_apps_Car_LocalMediaPlayer-7e37ae05a9c7c9ecf8569a5bfe752a54f660a6ba.zip | |
Move local media player.
Original sha1: f802a6f645c66e914ecfe2c1fd06e4dd1aadc6ef
Credits:
rni@
Bug: 32118797
Test: Manual.
Change-Id: I66c20653be687f10189042cabf84ddbc3aaf949b
Diffstat (limited to 'src/com')
5 files changed, 1084 insertions, 0 deletions
diff --git a/src/com/android/car/media/localmediaplayer/DataModel.java b/src/com/android/car/media/localmediaplayer/DataModel.java new file mode 100644 index 0000000..ba17306 --- /dev/null +++ b/src/com/android/car/media/localmediaplayer/DataModel.java @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2016, 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.car.media.localmediaplayer; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.media.browse.MediaBrowser.MediaItem; +import android.media.session.MediaSession.QueueItem; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.MediaStore; +import android.provider.MediaStore.Audio.AlbumColumns; +import android.provider.MediaStore.Audio.AudioColumns; +import android.service.media.MediaBrowserService.Result; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class DataModel { + private static final String TAG = "LMBDataModel"; + + private static final Uri[] ALL_AUDIO_URI = new Uri[] { + MediaStore.Audio.Media.INTERNAL_CONTENT_URI, + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + }; + + private static final Uri[] ALBUMS_URI = new Uri[] { + MediaStore.Audio.Albums.INTERNAL_CONTENT_URI, + MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI + }; + + private static final Uri[] ARTISTS_URI = new Uri[] { + MediaStore.Audio.Artists.INTERNAL_CONTENT_URI, + MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI + }; + + private static final Uri[] GENRES_URI = new Uri[] { + MediaStore.Audio.Genres.INTERNAL_CONTENT_URI, + MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI + }; + + private static final String QUERY_BY_KEY_WHERE_CLAUSE = + AudioColumns.ALBUM_KEY + "= ? or " + + AudioColumns.ARTIST_KEY + " = ? or " + + AudioColumns.TITLE_KEY + " = ? or " + + AudioColumns.DATA + " like ?"; + + private static final String EXTERNAL = "external"; + private static final String INTERNAL = "internal"; + + private static final Uri ART_BASE_URI = Uri.parse("content://media/external/audio/albumart"); + // Need a context to create this constant so it can't be static. + private final String DEFAULT_ALBUM_ART_URI; + + public static final String PATH_KEY = "PATH"; + + private Context mContext; + private ContentResolver mResolver; + private AsyncTask mPendingTask; + + private List<QueueItem> mQueue = new ArrayList<>(); + + public DataModel(Context context) { + mContext = context; + mResolver = context.getContentResolver(); + DEFAULT_ALBUM_ART_URI = + Utils.getUriForResource(context, R.drawable.ic_sd_storage_black).toString(); + } + + public void onQueryByFolder(String parentId, Result<List<MediaItem>> result) { + FilesystemListTask query = new FilesystemListTask(result, ALL_AUDIO_URI, mResolver); + queryInBackground(result, query); + } + + public void onQueryByAlbum(String parentId, Result<List<MediaItem>> result) { + QueryTask query = new QueryTask.Builder() + .setResolver(mResolver) + .setResult(result) + .setUri(ALBUMS_URI) + .setKeyColumn(AudioColumns.ALBUM_KEY) + .setTitleColumn(AudioColumns.ALBUM) + .setFlags(MediaItem.FLAG_BROWSABLE) + .build(); + queryInBackground(result, query); + } + + public void onQueryByArtist(String parentId, Result<List<MediaItem>> result) { + QueryTask query = new QueryTask.Builder() + .setResolver(mResolver) + .setResult(result) + .setUri(ARTISTS_URI) + .setKeyColumn(AudioColumns.ARTIST_KEY) + .setTitleColumn(AudioColumns.ARTIST) + .setFlags(MediaItem.FLAG_BROWSABLE) + .build(); + queryInBackground(result, query); + } + + public void onQueryByGenre(String parentId, Result<List<MediaItem>> result) { + QueryTask query = new QueryTask.Builder() + .setResolver(mResolver) + .setResult(result) + .setUri(GENRES_URI) + .setKeyColumn(MediaStore.Audio.Genres._ID) + .setTitleColumn(MediaStore.Audio.Genres.NAME) + .setFlags(MediaItem.FLAG_BROWSABLE) + .build(); + queryInBackground(result, query); + } + + private void queryInBackground(Result<List<MediaItem>> result, + AsyncTask<Void, Void, Void> task) { + result.detach(); + + if (mPendingTask != null) { + mPendingTask.cancel(true); + } + + mPendingTask = task; + task.execute(); + } + + public List<QueueItem> getQueue() { + return mQueue; + } + + public MediaMetadata getMetadata(String key) { + Cursor cursor = null; + MediaMetadata.Builder metadata = new MediaMetadata.Builder(); + try { + for (Uri uri : ALL_AUDIO_URI) { + cursor = mResolver.query(uri, null, AudioColumns.TITLE_KEY + " = ?", + new String[]{ key }, null); + if (cursor != null) { + int title = cursor.getColumnIndex(AudioColumns.TITLE); + int artist = cursor.getColumnIndex(AudioColumns.ARTIST); + int album = cursor.getColumnIndex(AudioColumns.ALBUM); + int albumId = cursor.getColumnIndex(AudioColumns.ALBUM_ID); + int duration = cursor.getColumnIndex(AudioColumns.DURATION); + + while (cursor.moveToNext()) { + metadata.putString(MediaMetadata.METADATA_KEY_TITLE, + cursor.getString(title)); + metadata.putString(MediaMetadata.METADATA_KEY_ARTIST, + cursor.getString(artist)); + metadata.putString(MediaMetadata.METADATA_KEY_ALBUM, + cursor.getString(album)); + metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, + cursor.getLong(duration)); + + String albumArt = DEFAULT_ALBUM_ART_URI; + Uri albumArtUri = ContentUris.withAppendedId(ART_BASE_URI, + cursor.getLong(albumId)); + try { + InputStream dummy = mResolver.openInputStream(albumArtUri); + albumArt = albumArtUri.toString(); + dummy.close(); + } catch (IOException e) { + // Ignored because the albumArt is intialized correctly anyway. + } + metadata.putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArt); + break; + } + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return metadata.build(); + } + + /** + * Note: This clears out the queue. You should have a local copy of the queue before calling + * this method. + */ + public void onQueryByKey(String lastCategory, String parentId, Result<List<MediaItem>> result) { + mQueue.clear(); + + QueryTask.Builder query = new QueryTask.Builder() + .setResolver(mResolver) + .setResult(result); + + Uri[] uri = null; + if (lastCategory.equals(LocalMediaBrowserService.GENRES_ID)) { + // Genres come from a different table and don't use the where clause from the + // usual media table so we need to have this condition. + try { + long id = Long.parseLong(parentId); + query.setUri(new Uri[] { + MediaStore.Audio.Genres.Members.getContentUri(EXTERNAL, id), + MediaStore.Audio.Genres.Members.getContentUri(INTERNAL, id) }); + } catch (NumberFormatException e) { + // This should never happen. + Log.e(TAG, "Incorrect key type: " + parentId + ", sending empty result"); + result.sendResult(new ArrayList<MediaItem>()); + return; + } + } else { + query.setUri(ALL_AUDIO_URI) + .setWhereClause(QUERY_BY_KEY_WHERE_CLAUSE) + .setWhereArgs(new String[] { parentId, parentId, parentId, parentId }); + } + + query.setKeyColumn(AudioColumns.TITLE_KEY) + .setTitleColumn(AudioColumns.TITLE) + .setSubtitleColumn(AudioColumns.ALBUM) + .setFlags(MediaItem.FLAG_PLAYABLE) + .setQueue(mQueue); + queryInBackground(result, query.build()); + } + + // This async task is similar enough to all the others that it feels like it can be unified + // but is different enough that unifying it makes the code for both cases look really weird + // and over paramterized so at the risk of being a little more verbose, this is separated out + // in the name of understandability. + private static class FilesystemListTask extends AsyncTask<Void, Void, Void> { + private static final String[] COLUMNS = { AudioColumns.DATA }; + private Result<List<MediaItem>> mResult; + private Uri[] mUris; + private ContentResolver mResolver; + + public FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris, + ContentResolver resolver) { + mResult = result; + mUris = uris; + mResolver = resolver; + } + + @Override + protected Void doInBackground(Void... voids) { + Set<String> paths = new HashSet<String>(); + + Cursor cursor = null; + for (Uri uri : mUris) { + try { + cursor = mResolver.query(uri, COLUMNS, null , null, null); + if (cursor != null) { + int pathColumn = cursor.getColumnIndex(AudioColumns.DATA); + + while (cursor.moveToNext()) { + // We want to de-dupe paths of each of the songs so we get just a list + // of containing directories. + String fullPath = cursor.getString(pathColumn); + int fileNameStart = fullPath.lastIndexOf(File.separator); + if (fileNameStart < 0) { + continue; + } + + String dirPath = fullPath.substring(0, fileNameStart); + paths.add(dirPath); + } + } + } catch (SQLiteException e) { + Log.e(TAG, "Failed to execute query " + e); // Stack trace is noisy. + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + // Take the list of deduplicated directories and put them into the results list with + // the full directory path as the key so we can match on it later. + List<MediaItem> results = new ArrayList<>(); + for (String path : paths) { + int dirNameStart = path.lastIndexOf(File.separator) + 1; + String dirName = path.substring(dirNameStart, path.length()); + MediaDescription description = new MediaDescription.Builder() + .setMediaId(path + "%") // Used in a like query. + .setTitle(dirName) + .setSubtitle(path) + .build(); + results.add(new MediaItem(description, MediaItem.FLAG_BROWSABLE)); + } + mResult.sendResult(results); + return null; + } + } + + private static class QueryTask extends AsyncTask<Void, Void, Void> { + private Result<List<MediaItem>> mResult; + private String[] mColumns; + private String mWhereClause; + private String[] mWhereArgs; + private String mKeyColumn; + private String mTitleColumn; + private String mSubtitleColumn; + private Uri[] mUris; + private int mFlags; + private ContentResolver mResolver; + private List<QueueItem> mQueue; + + private QueryTask(Builder builder) { + mColumns = builder.mColumns; + mWhereClause = builder.mWhereClause; + mWhereArgs = builder.mWhereArgs; + mKeyColumn = builder.mKeyColumn; + mTitleColumn = builder.mTitleColumn; + mUris = builder.mUris; + mFlags = builder.mFlags; + mResolver = builder.mResolver; + mResult = builder.mResult; + mQueue = builder.mQueue; + mSubtitleColumn = builder.mSubtitleColumn; + } + + @Override + protected Void doInBackground(Void... voids) { + List<MediaItem> results = new ArrayList<>(); + + long idx = 0; + + Cursor cursor = null; + for (Uri uri : mUris) { + try { + cursor = mResolver.query(uri, mColumns, mWhereClause, mWhereArgs, null); + if (cursor != null) { + int keyColumn = cursor.getColumnIndex(mKeyColumn); + int titleColumn = cursor.getColumnIndex(mTitleColumn); + int pathColumn = cursor.getColumnIndex(AudioColumns.DATA); + int subtitleColumn = -1; + if (mSubtitleColumn != null) { + subtitleColumn = cursor.getColumnIndex(mSubtitleColumn); + } + + while (cursor.moveToNext()) { + Bundle path = new Bundle(); + if (pathColumn != -1) { + path.putString(PATH_KEY, cursor.getString(pathColumn)); + } + + MediaDescription.Builder builder = new MediaDescription.Builder() + .setMediaId(cursor.getString(keyColumn)) + .setTitle(cursor.getString(titleColumn)) + .setExtras(path); + + if (subtitleColumn != -1) { + builder.setSubtitle(cursor.getString(subtitleColumn)); + } + + MediaDescription description = builder.build(); + results.add(new MediaItem(description, mFlags)); + + // We rebuild the queue here so if the user selects the item then we + // can immediately use this queue. + if (mQueue != null) { + mQueue.add(new QueueItem(description, idx)); + } + idx++; + } + } + } catch (SQLiteException e) { + // Sometimes tables don't exist if the media scanner hasn't seen data of that + // type yet. For example, the genres table doesn't seem to exist at all until + // the first time a song with a genre is encountered. If we hit an exception, + // the result is never sent causing the other end to hang up, which is a bad + // thing. We can instead just be resilient and return an empty list. + Log.i(TAG, "Failed to execute query " + e); // Stack trace is noisy. + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + mResult.sendResult(results); + return null; // Ignored. + } + + // + // Boilerplate Alert! + // + public static class Builder { + private Result<List<MediaItem>> mResult; + private String[] mColumns; + private String mWhereClause; + private String[] mWhereArgs; + private String mKeyColumn; + private String mTitleColumn; + private String mSubtitleColumn; + private Uri[] mUris; + private int mFlags; + private ContentResolver mResolver; + private List<QueueItem> mQueue; + + public Builder setColumns(String[] columns) { + mColumns = columns; + return this; + } + + public Builder setWhereClause(String whereClause) { + mWhereClause = whereClause; + return this; + } + + public Builder setWhereArgs(String[] whereArgs) { + mWhereArgs = whereArgs; + return this; + } + + public Builder setUri(Uri[] uris) { + mUris = uris; + return this; + } + + public Builder setKeyColumn(String keyColumn) { + mKeyColumn = keyColumn; + return this; + } + + public Builder setTitleColumn(String titleColumn) { + mTitleColumn = titleColumn; + return this; + } + + public Builder setSubtitleColumn(String subtitleColumn) { + mSubtitleColumn = subtitleColumn; + return this; + } + + public Builder setFlags(int flags) { + mFlags = flags; + return this; + } + + public Builder setResult(Result<List<MediaItem>> result) { + mResult = result; + return this; + } + + public Builder setResolver(ContentResolver resolver) { + mResolver = resolver; + return this; + } + + public Builder setQueue(List<QueueItem> queue) { + mQueue = queue; + return this; + } + + public QueryTask build() { + if (mUris == null || mKeyColumn == null || mResolver == null || + mResult == null || mTitleColumn == null) { + throw new IllegalStateException( + "uri, keyColumn, resolver, result and titleColumn are required."); + } + return new QueryTask(this); + } + } + } +} diff --git a/src/com/android/car/media/localmediaplayer/LocalMediaBrowserService.java b/src/com/android/car/media/localmediaplayer/LocalMediaBrowserService.java new file mode 100644 index 0000000..a2b8033 --- /dev/null +++ b/src/com/android/car/media/localmediaplayer/LocalMediaBrowserService.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2016, 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.car.media.localmediaplayer; + +import android.content.Intent; +import android.media.MediaDescription; +import android.media.browse.MediaBrowser; +import android.media.session.MediaSession; +import android.os.Bundle; +import android.service.media.MediaBrowserService; +import android.support.annotation.Nullable; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +public class LocalMediaBrowserService extends MediaBrowserService { + private static final String TAG = "LMBService"; + private static final String ROOT_ID = "__ROOT__"; + private static final String MEDIA_SESSION_TAG = "LOCAL_MEDIA_SESSION"; + + static final String FOLDERS_ID = "__FOLDERS__"; + static final String ARTISTS_ID = "__ARTISTS__"; + static final String ALBUMS_ID = "__ALBUMS__"; + static final String GENRES_ID = "__GENRES__"; + + private BrowserRoot mRoot = new BrowserRoot(ROOT_ID, null); + List<MediaBrowser.MediaItem> mRootItems = new ArrayList<>(); + + private DataModel mDataModel; + private Player mPlayer; + private MediaSession mSession; + private String mLastCategory; + + private void addRootItems() { + MediaDescription folders = new MediaDescription.Builder() + .setMediaId(FOLDERS_ID) + .setTitle(getString(R.string.folders_title)) + .setIconUri(Utils.getUriForResource(this, R.drawable.ic_folder)) + .build(); + mRootItems.add(new MediaBrowser.MediaItem(folders, MediaBrowser.MediaItem.FLAG_BROWSABLE)); + + MediaDescription albums = new MediaDescription.Builder() + .setMediaId(ALBUMS_ID) + .setTitle(getString(R.string.albums_title)) + .setIconUri(Utils.getUriForResource(this, R.drawable.ic_album)) + .build(); + mRootItems.add(new MediaBrowser.MediaItem(albums, MediaBrowser.MediaItem.FLAG_BROWSABLE)); + + MediaDescription artists = new MediaDescription.Builder() + .setMediaId(ARTISTS_ID) + .setTitle(getString(R.string.artists_title)) + .setIconUri(Utils.getUriForResource(this, R.drawable.ic_artist)) + .build(); + mRootItems.add(new MediaBrowser.MediaItem(artists, MediaBrowser.MediaItem.FLAG_BROWSABLE)); + + MediaDescription genres = new MediaDescription.Builder() + .setMediaId(GENRES_ID) + .setTitle(getString(R.string.genres_title)) + .setIconUri(Utils.getUriForResource(this, R.drawable.ic_genre)) + .build(); + mRootItems.add(new MediaBrowser.MediaItem(genres, MediaBrowser.MediaItem.FLAG_BROWSABLE)); + } + + @Override + public void onCreate() { + super.onCreate(); + + // TODO: This doesn't handle the case where the user revokes the permission very well, the + // prompt will only show up once this service has been recreated which is non-deterministic. + if (!Utils.hasRequiredPermissions(this)) { + Intent intent = new Intent(this, PermissionsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + + mDataModel = new DataModel(this); + addRootItems(); + mSession = new MediaSession(this, MEDIA_SESSION_TAG); + setSessionToken(mSession.getSessionToken()); + mPlayer = new Player(this, mSession, mDataModel); + mSession.setCallback(mPlayer); + mSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS + | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + } + + @Override + public void onDestroy() { + mPlayer.destroy(); + mSession.release(); + super.onDestroy(); + } + + @Nullable + @Override + public BrowserRoot onGetRoot(String clientName, int clientUid, Bundle rootHints) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onGetRoot clientName=" + clientName); + } + return mRoot; + } + + @Override + public void onLoadChildren(String parentId, Result<List<MediaBrowser.MediaItem>> result) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onLoadChildren parentId=" + parentId); + } + + switch (parentId) { + case ROOT_ID: + result.sendResult(mRootItems); + mLastCategory = parentId; + break; + case FOLDERS_ID: + mDataModel.onQueryByFolder(parentId, result); + mLastCategory = parentId; + break; + case ALBUMS_ID: + mDataModel.onQueryByAlbum(parentId, result); + mLastCategory = parentId; + break; + case ARTISTS_ID: + mDataModel.onQueryByArtist(parentId, result); + mLastCategory = parentId; + break; + case GENRES_ID: + mDataModel.onQueryByGenre(parentId, result); + mLastCategory = parentId; + break; + default: + mDataModel.onQueryByKey(mLastCategory, parentId, result); + } + } +} diff --git a/src/com/android/car/media/localmediaplayer/PermissionsActivity.java b/src/com/android/car/media/localmediaplayer/PermissionsActivity.java new file mode 100644 index 0000000..24b1dc0 --- /dev/null +++ b/src/com/android/car/media/localmediaplayer/PermissionsActivity.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2016, 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.car.media.localmediaplayer; + +import android.app.Activity; +import android.os.Bundle; + +public class PermissionsActivity extends Activity { + private static final int REQUEST_CODE = 42; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Check again, just in case. + if (!Utils.hasRequiredPermissions(this)) { + requestPermissions(Utils.PERMISSIONS, REQUEST_CODE); + } else { + finish(); + } + } + + @Override + public void onRequestPermissionsResult(int request, String[] permissions, int[] results) { + // The media browser displays an error anyway if it doesn't have the required permissions + // so we call finish irrespective of the grant result. This whole activity exists just + // for the purpose of trampolining the permissions request anyway. + finish(); + } +} diff --git a/src/com/android/car/media/localmediaplayer/Player.java b/src/com/android/car/media/localmediaplayer/Player.java new file mode 100644 index 0000000..591524f --- /dev/null +++ b/src/com/android/car/media/localmediaplayer/Player.java @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2016, 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.car.media.localmediaplayer; + +import android.content.Context; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.session.MediaSession; +import android.media.session.MediaSession.QueueItem; +import android.media.session.PlaybackState; +import android.os.Bundle; +import android.util.Log; + +import java.io.IOException; +import java.util.List; + +/** + * TODO: Consider doing all content provider accesses and player operations asynchronously. + */ +public class Player extends MediaSession.Callback { + private static final String TAG = "LMPlayer"; + + private static final float PLAYBACK_SPEED = 1.0f; + private static final float PLAYBACK_SPEED_STOPPED = 1.0f; + private static final long PLAYBACK_POSITION_STOPPED = 0; + + // Note: Queues loop around so next/previous are always available. + private static final long PLAYING_ACTIONS = PlaybackState.ACTION_PAUSE + | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM; + + private static final long PAUSED_ACTIONS = PlaybackState.ACTION_PLAY + | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS; + + private static final long STOPPED_ACTIONS = PlaybackState.ACTION_PLAY + | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS; + + private final Context mContext; + private final MediaSession mSession; + private final AudioManager mAudioManager; + private final PlaybackState mErrorState; + private final DataModel mDataModel; + + private List<QueueItem> mQueue; + private int mCurrentQueueIdx = 0; + + // TODO: Use multiple media players for gapless playback. + private final MediaPlayer mMediaPlayer; + + + public Player(Context context, MediaSession session, DataModel dataModel) { + mContext = context; + mDataModel = dataModel; + mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + mSession = session; + + mMediaPlayer = new MediaPlayer(); + mMediaPlayer.reset(); + mMediaPlayer.setOnCompletionListener(mOnCompletionListener); + mErrorState = new PlaybackState.Builder() + .setState(PlaybackState.STATE_ERROR, 0, 0) + .setErrorMessage(context.getString(R.string.playback_error)) + .build(); + } + + @Override + public void onPlay() { + super.onPlay(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onPlay"); + } + int result = mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + if (result == AudioManager.AUDIOFOCUS_GAIN) { + resumePlayback(); + } else { + Log.e(TAG, "Failed to acquire audio focus"); + } + } + + @Override + public void onPause() { + super.onPause(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onPause"); + } + pausePlayback(); + mAudioManager.abandonAudioFocus(mAudioFocusListener); + } + + public void destroy() { + stopPlayback(); + mAudioManager.abandonAudioFocus(mAudioFocusListener); + mMediaPlayer.release(); + } + + private void startPlayback(String key) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "startPlayback()"); + } + + List<QueueItem> queue = mDataModel.getQueue(); + int idx = 0; + int foundIdx = -1; + for (QueueItem item : queue) { + if (item.getDescription().getMediaId().equals(key)) { + foundIdx = idx; + break; + } + idx++; + } + + if (foundIdx == -1) { + mSession.setPlaybackState(mErrorState); + return; + } + + mQueue = queue; + mCurrentQueueIdx = foundIdx; + QueueItem current = mQueue.get(mCurrentQueueIdx); + String path = current.getDescription().getExtras().getString(DataModel.PATH_KEY); + MediaMetadata metadata = mDataModel.getMetadata(current.getDescription().getMediaId()); + mSession.setQueueTitle(mContext.getString(R.string.playlist)); + mSession.setQueue(queue); + + try { + play(path, metadata); + } catch (IOException e) { + Log.e(TAG, "Playback failed.", e); + mSession.setPlaybackState(mErrorState); + } + } + + private void resumePlayback() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "resumePlayback()"); + } + + updatePlaybackStatePlaying(); + + if (!mMediaPlayer.isPlaying()) { + mMediaPlayer.start(); + } + } + + private void updatePlaybackStatePlaying() { + if (!mSession.isActive()) { + mSession.setActive(true); + } + + PlaybackState state = new PlaybackState.Builder() + .setState(PlaybackState.STATE_PLAYING, + mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED) + .setActions(PLAYING_ACTIONS) + .setActiveQueueItemId(mCurrentQueueIdx) + .build(); + mSession.setPlaybackState(state); + } + + private void pausePlayback() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "pausePlayback()"); + } + + long currentPosition = 0; + if (mMediaPlayer.isPlaying()) { + currentPosition = mMediaPlayer.getCurrentPosition(); + mMediaPlayer.pause(); + } + + PlaybackState state = new PlaybackState.Builder() + .setState(PlaybackState.STATE_PAUSED, currentPosition, PLAYBACK_SPEED_STOPPED) + .setActions(PAUSED_ACTIONS) + .build(); + mSession.setPlaybackState(state); + } + + private void stopPlayback() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "stopPlayback()"); + } + + if (mMediaPlayer.isPlaying()) { + mMediaPlayer.stop(); + } + + PlaybackState state = new PlaybackState.Builder() + .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED, + PLAYBACK_SPEED_STOPPED) + .setActions(STOPPED_ACTIONS) + .build(); + mSession.setPlaybackState(state); + } + + private void advance() throws IOException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "advance()"); + } + // Go to the next song if one exists. Note that if you were to support gapless + // playback, you would have to change this code such that you had a currently + // playing and a loading MediaPlayer and juggled between them while also calling + // setNextMediaPlayer. + + if (mQueue != null) { + // Keep looping around when we run off the end of our current queue. + mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size(); + playCurrentQueueIndex(); + } else { + stopPlayback(); + } + } + + private void retreat() throws IOException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "retreat()"); + } + // Go to the next song if one exists. Note that if you were to support gapless + // playback, you would have to change this code such that you had a currently + // playing and a loading MediaPlayer and juggled between them while also calling + // setNextMediaPlayer. + if (mQueue != null) { + // Keep looping around when we run off the end of our current queue. + mCurrentQueueIdx--; + if (mCurrentQueueIdx < 0) { + mCurrentQueueIdx = mQueue.size() - 1; + } + playCurrentQueueIndex(); + } else { + stopPlayback(); + } + } + + private void playCurrentQueueIndex() throws IOException { + MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription(); + String path = next.getExtras().getString(DataModel.PATH_KEY); + MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId()); + + play(path, metadata); + } + + private void play(String path, MediaMetadata metadata) throws IOException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "play path=" + path + " metadata=" + metadata); + } + + mMediaPlayer.reset(); + mMediaPlayer.setDataSource(path); + mMediaPlayer.prepare(); + mMediaPlayer.start(); + + if (metadata != null) { + mSession.setMetadata(metadata); + } + updatePlaybackStatePlaying(); + } + + private void safeAdvance() { + try { + advance(); + } catch (IOException e) { + Log.e(TAG, "Failed to advance.", e); + mSession.setPlaybackState(mErrorState); + } + } + + private void safeRetreat() { + try { + retreat(); + } catch (IOException e) { + Log.e(TAG, "Failed to advance.", e); + mSession.setPlaybackState(mErrorState); + } + } + + @Override + public void onPlayFromMediaId(String mediaId, Bundle extras) { + super.onPlayFromMediaId(mediaId, extras); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras); + } + + int result = mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + if (result == AudioManager.AUDIOFOCUS_GAIN) { + startPlayback(mediaId); + } else { + Log.e(TAG, "Failed to acquire audio focus"); + } + } + + @Override + public void onSkipToNext() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onSkipToNext()"); + } + safeAdvance(); + } + + @Override + public void onSkipToPrevious() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onSkipToPrevious()"); + } + safeRetreat(); + } + + @Override + public void onSkipToQueueItem(long id) { + int idx = (int) id; + MediaSession.QueueItem item = mQueue.get(idx); + MediaDescription description = item.getDescription(); + + String path = description.getExtras().getString(DataModel.PATH_KEY); + MediaMetadata metadata = mDataModel.getMetadata(description.getMediaId()); + + try { + play(path, metadata); + mCurrentQueueIdx = idx; + } catch (IOException e) { + Log.e(TAG, "Failed to play.", e); + mSession.setPlaybackState(mErrorState); + } + } + + private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int focus) { + switch (focus) { + case AudioManager.AUDIOFOCUS_GAIN: + resumePlayback(); + break; + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + pausePlayback(); + break; + } + } + }; + + private OnCompletionListener mOnCompletionListener = new OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mediaPlayer) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onCompletion()"); + } + safeAdvance(); + } + }; +} diff --git a/src/com/android/car/media/localmediaplayer/Utils.java b/src/com/android/car/media/localmediaplayer/Utils.java new file mode 100644 index 0000000..a285589 --- /dev/null +++ b/src/com/android/car/media/localmediaplayer/Utils.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2016, 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.car.media.localmediaplayer; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.net.Uri; + +public class Utils { + static final String[] PERMISSIONS = { + android.Manifest.permission.READ_EXTERNAL_STORAGE + }; + + static Uri getUriForResource(Context context, int id) { + Resources res = context.getResources(); + return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + res.getResourcePackageName(id) + + "/" + res.getResourceTypeName(id) + + "/" + res.getResourceEntryName(id)); + } + + static boolean hasRequiredPermissions(Context context) { + for (String permission : PERMISSIONS) { + if (context.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } +} |
