summaryrefslogtreecommitdiffstats
path: root/src/com
diff options
context:
space:
mode:
authorRakesh Iyer <rni@google.com>2016-10-19 23:31:13 -0700
committerRakesh Iyer <rni@google.com>2016-10-20 00:26:09 -0700
commit7e37ae05a9c7c9ecf8569a5bfe752a54f660a6ba (patch)
treeac653a86179441ec93fccc9047a0ffff7d4722b7 /src/com
parent468bbbd104cd450b404fece9ff94628b1953e293 (diff)
downloadplatform_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')
-rw-r--r--src/com/android/car/media/localmediaplayer/DataModel.java480
-rw-r--r--src/com/android/car/media/localmediaplayer/LocalMediaBrowserService.java147
-rw-r--r--src/com/android/car/media/localmediaplayer/PermissionsActivity.java43
-rw-r--r--src/com/android/car/media/localmediaplayer/Player.java369
-rw-r--r--src/com/android/car/media/localmediaplayer/Utils.java45
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;
+ }
+}