diff options
29 files changed, 2442 insertions, 0 deletions
diff --git a/MusicDemo/.gitignore b/MusicDemo/.gitignore new file mode 100644 index 0000000..963e828 --- /dev/null +++ b/MusicDemo/.gitignore @@ -0,0 +1,5 @@ +*.iml +.gradle +.idea +build/ +local.properties diff --git a/MusicDemo/README.txt b/MusicDemo/README.txt new file mode 100644 index 0000000..97a506e --- /dev/null +++ b/MusicDemo/README.txt @@ -0,0 +1,70 @@ +Android Automobile sample +========================= + + +Integration points +------------------ + +MusicService.java is the main entry point to the integration. It needs to: + + - extend android.service.media.MediaBrowserService, implementing the media browsing related methods onGetRoot and onLoadChildren; + - start a new MediaSession and notify it's parent of the session's token (super.setSessionToken()); + - set a callback on the MediaSession. The callback will receive all the user's actions, like play, pause, etc; + - handle all the actual music playing using any method your app prefers (for example, the Android MediaPlayer class) + - update info about the playing item and the playing queue using MediaSession (setMetadata, setPlaybackState, setQueue, setQueueTitle, etc) + - handle AudioManager focus change events and react appropriately (eg, pause when audio focus is lost) + - declare a meta-data tag in AndroidManifest.xml linking to a xml resource + with a <automotiveApp> root element. For a media app, this must include + an <uses name="media"/> element as a child. + For example, in AndroidManifest.xml: + <meta-data android:name="com.google.android.gms.car.application" + android:resource="@xml/automotive_app_desc"/> + And in res/values/automotive_app_desc.xml: + <?xml version="1.0" encoding="utf-8"?> + <automotiveApp> + <uses name="media"/> + </automotiveApp> + + - be declared in AndroidManifest as an intent receiver for the action android.media.browse.MediaBrowserService: + + <!-- Implement a service --> + <service + android:name=".service.MusicService" + android:exported="true" + > + <intent-filter> + <action android:name="android.media.browse.MediaBrowserService" /> + </intent-filter> + </service> + + +Optionally, you can listen to special intents that notify your app when a car is connected/disconnected. This may be useful if your app has special requirements when running on a car - for example, different media or ads. See CarPlugReceiver for more information. + + +Customization +------------- + +The car media app has only a few customization opportunities. You may: + +- Set the background color by using Android L primary color: + <style name="AppTheme" parent="android:Theme.Material"> + <item name="android:colorPrimary">#ff0000</item> + </style> + +- Add custom actions in the state passed to setPlaybackState(state) + +- Handle custom actions in the MediaSession.Callback.onCustomAction + + + +Known issues: +------------- + +- Sample: Resuming after pause makes the "Skip to previous" button disappear + +- Sample: playFromSearch creates a queue with search results, but then skip to next/previous don't work correctly because the queue is recreated without the search criteria + +- Emulator: running menu->search twice throws an exception. + +- Emulator: Under some circumstances, stop or onDestroy may never get called on MusicService and the MediaPlayer keeps locking some resources. Then, mediaPlayer.setDataSource on a new MediaPlayer object halts (probably) due to a deadlock. The workaround is to reboot the device. + diff --git a/MusicDemo/build.gradle b/MusicDemo/build.gradle new file mode 100644 index 0000000..a0d7e19 --- /dev/null +++ b/MusicDemo/build.gradle @@ -0,0 +1,27 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:0.12.+' + } +} + +apply plugin: 'android' + +android { + compileSdkVersion "android-L" + buildToolsVersion "21.0.0 rc1" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_7 + targetCompatibility JavaVersion.VERSION_1_7 + } +} + + +dependencies { + compile 'com.android.support:support-v4:18.0.+' + compile 'com.android.support:appcompat-v7:18.0.+' +} diff --git a/MusicDemo/gradle.properties b/MusicDemo/gradle.properties new file mode 100644 index 0000000..5d08ba7 --- /dev/null +++ b/MusicDemo/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Settings specified in this file will override any Gradle settings +# configured through the IDE. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true
\ No newline at end of file diff --git a/MusicDemo/gradle/wrapper/gradle-wrapper.jar b/MusicDemo/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..8c0fb64 --- /dev/null +++ b/MusicDemo/gradle/wrapper/gradle-wrapper.jar diff --git a/MusicDemo/gradle/wrapper/gradle-wrapper.properties b/MusicDemo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1e61d1f --- /dev/null +++ b/MusicDemo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Apr 10 15:27:10 PDT 2013 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=http\://services.gradle.org/distributions/gradle-1.12-all.zip diff --git a/MusicDemo/gradlew b/MusicDemo/gradlew new file mode 100755 index 0000000..91a7e26 --- /dev/null +++ b/MusicDemo/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/MusicDemo/proguard-project.txt b/MusicDemo/proguard-project.txt new file mode 100644 index 0000000..f2fe155 --- /dev/null +++ b/MusicDemo/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/MusicDemo/src/main/AndroidManifest.xml b/MusicDemo/src/main/AndroidManifest.xml new file mode 100644 index 0000000..608dbc5 --- /dev/null +++ b/MusicDemo/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.example.android.musicservicedemo" + android:versionCode="1" + android:versionName="1.0" > + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.WAKE_LOCK" /> + + <uses-sdk + android:minSdkVersion="21" + android:targetSdkVersion="21" /> + + <application + android:allowBackup="true" + android:icon="@drawable/ic_launcher" + android:label="@string/app_name" + android:theme="@style/AppTheme"> + + <meta-data android:name="com.google.android.gms.car.application" + android:resource="@xml/automotive_app_desc"/> + + <service + android:name=".MusicService" + android:exported="true" + > + <intent-filter> + <action android:name="android.media.browse.MediaBrowserService" /> + </intent-filter> + </service> + + <!-- (OPTIONAL) Use a broadcast receiver to listen to car connect/disconnect events --> + <receiver + android:name=".CarConnectionReceiver" + android:permission="com.google.android.gms.permission.CAR" > + <intent-filter> + <action android:name="com.google.android.gms.car.CONNECTED" /> + </intent-filter> + <intent-filter> + <action android:name="com.google.android.gms.car.DISCONNECTED" /> + </intent-filter> + </receiver> + + </application> + +</manifest>
\ No newline at end of file diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/CarConnectionReceiver.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/CarConnectionReceiver.java new file mode 100644 index 0000000..de9ef4f --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/CarConnectionReceiver.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2014 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.example.android.musicservicedemo; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.example.android.musicservicedemo.utils.LogHelper; + +/** + * Broadcast receiver that gets notified whenever the device is connected to a compatible car. + */ +public class CarConnectionReceiver extends BroadcastReceiver { + + private static final String TAG = "CarPlugReceiver"; + + private static final String CONNECTED_ACTION = "com.google.android.gms.car.CONNECTED"; + private static final String DISCONNECTED_ACTION = "com.google.android.gms.car.DISCONNECTED"; + + @Override + public void onReceive(Context context, Intent intent) { + if (CONNECTED_ACTION.equals(intent.getAction())) { + LogHelper.i(TAG, "Device is connected to Android Auto"); + } else if (DISCONNECTED_ACTION.equals(intent.getAction())) { + LogHelper.i(TAG, "Device is disconnected from Android Auto"); + } else { + LogHelper.w(TAG, "Received unexpected broadcast intent. Intent action: ", + intent.getAction()); + } + } +} diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/MediaNotification.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/MediaNotification.java new file mode 100644 index 0000000..33d14c1 --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/MediaNotification.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2014 Google Inc. All Rights Reserved. + * + * 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.example.android.musicservicedemo; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.os.AsyncTask; +import android.util.SparseArray; + +import com.example.android.musicservicedemo.utils.BitmapHelper; +import com.example.android.musicservicedemo.utils.LogHelper; + +import java.io.IOException; + +/** + * Keeps track of a notification and updates it automatically for a given + * MediaSession. Maintaining a visible notification (usually) guarantees that the music service + * won't be killed during playback. + */ +public class MediaNotification extends BroadcastReceiver { + private static final String TAG = "MediaNotification"; + + private static final int NOTIFICATION_ID = 412; + + public static final String ACTION_PAUSE = "com.example.android.musicservicedemo.pause"; + public static final String ACTION_PLAY = "com.example.android.musicservicedemo.play"; + public static final String ACTION_PREV = "com.example.android.musicservicedemo.prev"; + public static final String ACTION_NEXT = "com.example.android.musicservicedemo.next"; + + + private final MusicService mService; + private MediaSession.Token mSessionToken; + private MediaController mController; + private MediaController.TransportControls mTransportControls; + private final SparseArray<PendingIntent> mIntents = new SparseArray<PendingIntent>(); + + private PlaybackState mPlaybackState; + private MediaMetadata mMetadata; + + private Notification.Builder mNotificationBuilder; + private NotificationManager mNotificationManager; + private Notification.Action mPlayPauseAction; + + private String mCurrentAlbumArt; + + private boolean mStarted = false; + + public MediaNotification(MusicService service) { + mService = service; + updateSessionToken(); + + mNotificationManager = (NotificationManager) mService + .getSystemService(Context.NOTIFICATION_SERVICE); + + String pkg = mService.getPackageName(); + mIntents.put(android.R.drawable.ic_media_pause, PendingIntent.getBroadcast(mService, 100, + new Intent(ACTION_PAUSE).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT)); + mIntents.put(android.R.drawable.ic_media_play, PendingIntent.getBroadcast(mService, 100, + new Intent(ACTION_PLAY).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT)); + mIntents.put(android.R.drawable.ic_media_previous, PendingIntent.getBroadcast(mService, 100, + new Intent(ACTION_PREV).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT)); + mIntents.put(android.R.drawable.ic_media_next, PendingIntent.getBroadcast(mService, 100, + new Intent(ACTION_NEXT).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT)); + } + + /** + * Posts the notification and starts tracking the session to keep it + * updated. The notification will automatically be removed if the session is + * destroyed before {@link #stopNotification} is called. + */ + public void startNotification() { + if (!mStarted) { + mController.registerCallback(mCb); + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_NEXT); + filter.addAction(ACTION_PAUSE); + filter.addAction(ACTION_PLAY); + filter.addAction(ACTION_PREV); + mService.registerReceiver(this, filter); + + mMetadata = mController.getMetadata(); + mPlaybackState = mController.getPlaybackState(); + + mStarted = true; + // The notification must be updated after setting started to true + updateNotificationMetadata(); + } + } + + /** + * Removes the notification and stops tracking the session. If the session + * was destroyed this has no effect. + */ + public void stopNotification() { + mStarted = false; + mController.unregisterCallback(mCb); + try { + mService.unregisterReceiver(this); + } catch (IllegalArgumentException ex) { + // ignore if the receiver is not registered. + } + mService.stopForeground(true); + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + LogHelper.d(TAG, "Received intent with action " + action); + if (ACTION_PAUSE.equals(action)) { + mTransportControls.pause(); + } else if (ACTION_PLAY.equals(action)) { + mTransportControls.play(); + } else if (ACTION_NEXT.equals(action)) { + mTransportControls.skipToNext(); + } else if (ACTION_PREV.equals(action)) { + mTransportControls.skipToPrevious(); + } + } + + /** + * Update the state based on a change on the session token. Called either when + * we are running for the first time or when the media session owner has destroyed the session + * (see {@link android.media.session.MediaController.Callback#onSessionDestroyed()}) + */ + private void updateSessionToken() { + MediaSession.Token freshToken = mService.getSessionToken(); + if (mSessionToken == null || !mSessionToken.equals(freshToken)) { + if (mController != null) { + mController.unregisterCallback(mCb); + } + mSessionToken = freshToken; + mController = new MediaController(mService, mSessionToken); + mTransportControls = mController.getTransportControls(); + if (mStarted) { + mController.registerCallback(mCb); + } + } + } + + private final MediaController.Callback mCb = new MediaController.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackState state) { + mPlaybackState = state; + LogHelper.d(TAG, "Received new playback state", state); + updateNotificationPlaybackState(); + } + + @Override + public void onMetadataChanged(MediaMetadata metadata) { + mMetadata = metadata; + LogHelper.d(TAG, "Received new metadata ", metadata); + updateNotificationMetadata(); + } + + @Override + public void onSessionDestroyed() { + super.onSessionDestroyed(); + LogHelper.d(TAG, "Session was destroyed, resetting to the new session token"); + updateSessionToken(); + } + }; + + private void updateNotificationMetadata() { + LogHelper.d(TAG, "updateNotificationMetadata. mMetadata=" + mMetadata); + if (mMetadata == null) { + return; + } + + updatePlayPauseAction(); + + boolean firstRun = false; + + if (mNotificationBuilder == null) { + firstRun = true; + + mNotificationBuilder = new Notification.Builder(mService); + + mNotificationBuilder + .addAction(android.R.drawable.ic_media_previous, + mService.getString(R.string.label_previous), + mIntents.get(android.R.drawable.ic_media_previous)) + .addAction(mPlayPauseAction) + .addAction(android.R.drawable.ic_media_next, + mService.getString(R.string.label_next), + mIntents.get(android.R.drawable.ic_media_next)) + .setStyle(new Notification.MediaStyle() + .setShowActionsInCompactView(1) // only show play/pause in compact view + .setMediaSession(mSessionToken)) + .setColor(android.R.attr.colorPrimaryDark) + .setSmallIcon(R.drawable.ic_notification) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setUsesChronometer(true); + } + + MediaDescription description = mMetadata.getDescription(); + Bitmap art = description.getIconBitmap(); + mNotificationBuilder + .setContentTitle(description.getTitle()) + .setContentText(description.getSubtitle()) + .setLargeIcon(art); + + updateNotificationPlaybackState(); + + if (firstRun) { + mService.startForeground(NOTIFICATION_ID, mNotificationBuilder.build()); + } else { + mNotificationManager.notify(NOTIFICATION_ID, mNotificationBuilder.build()); + } + + if (art == null && description.getIconUri() != null) { + // This sample assumes the iconUri will be a valid URL formatted String, but + // it can actually be any valid Android Uri formatted String. + String albumUrl = description.getIconUri().toString(); + if (mCurrentAlbumArt == null || !mCurrentAlbumArt.equals(albumUrl)) { + mCurrentAlbumArt = albumUrl; + // async fetch the album art icon + getBitmapFromURLAsync(albumUrl); + } + } + } + + private void updatePlayPauseAction() { + LogHelper.d(TAG, "updatePlayPauseAction"); + String playPauseLabel = ""; + int playPauseIcon; + if (mPlaybackState.getState() == PlaybackState.STATE_PLAYING) { + playPauseLabel = mService.getString(R.string.label_pause); + playPauseIcon = android.R.drawable.ic_media_pause; + } else { + playPauseLabel = mService.getString(R.string.label_play); + playPauseIcon = android.R.drawable.ic_media_play; + } + if (mPlayPauseAction == null) { + mPlayPauseAction = new Notification.Action(playPauseIcon, playPauseLabel, + mIntents.get(playPauseIcon)); + } else { + mPlayPauseAction.icon = playPauseIcon; + mPlayPauseAction.title = playPauseLabel; + mPlayPauseAction.actionIntent = mIntents.get(playPauseIcon); + } + } + + private void updateNotificationPlaybackState() { + LogHelper.d(TAG, "updateNotificationPlaybackState. mPlaybackState=" + mPlaybackState); + if (mPlaybackState == null || !mStarted) { + LogHelper.d(TAG, "updateNotificationPlaybackState. cancelling notification!"); + mService.stopForeground(true); + return; + } + if (mNotificationBuilder == null) { + LogHelper.d(TAG, "updateNotificationPlaybackState. there is no notificationBuilder. Ignoring request to update state!"); + return; + } + if (mPlaybackState.getPosition() >= 0) { + LogHelper.d(TAG, "updateNotificationPlaybackState. updating playback position to ", + (System.currentTimeMillis() - mPlaybackState.getPosition()) / 1000, " seconds"); + mNotificationBuilder + .setWhen(System.currentTimeMillis() - mPlaybackState.getPosition()) + .setShowWhen(true) + .setUsesChronometer(true); + mNotificationBuilder.setShowWhen(true); + } else { + LogHelper.d(TAG, "updateNotificationPlaybackState. hiding playback position"); + mNotificationBuilder + .setWhen(0) + .setShowWhen(false) + .setUsesChronometer(false); + } + + updatePlayPauseAction(); + + mNotificationManager.notify(NOTIFICATION_ID, mNotificationBuilder.build()); + } + + public void getBitmapFromURLAsync(final String source) { + LogHelper.d(TAG, "getBitmapFromURLAsync: starting asynctask to fetch ", source); + new AsyncTask() { + @Override + protected Object doInBackground(Object[] objects) { + try { + Bitmap bitmap = BitmapHelper.fetchAndRescaleBitmap(source, + BitmapHelper.MEDIA_ART_BIG_WIDTH, BitmapHelper.MEDIA_ART_BIG_HEIGHT); + if (mMetadata != null) { + MediaDescription currentDescription = mMetadata.getDescription(); + // If the media is still the same, update the notification: + if (mNotificationBuilder != null && + currentDescription.getIconUri().toString().equals(source)) { + LogHelper.d(TAG, "getBitmapFromURLAsync: set bitmap to ", source); + mCurrentAlbumArt = source; + mNotificationBuilder.setLargeIcon(bitmap); + mNotificationManager.notify(NOTIFICATION_ID, + mNotificationBuilder.build()); + } + } + } catch (IOException e) { + LogHelper.e(TAG, e, "getBitmapFromURLAsync: " + source); + } + return null; + } + }.execute(); + } + +} diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/MusicService.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/MusicService.java new file mode 100644 index 0000000..c2c535d --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/MusicService.java @@ -0,0 +1,878 @@ +/* + * Copyright (C) 2014 Google Inc. All Rights Reserved. + * + * 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.example.android.musicservicedemo; + +import android.content.Context; +import android.media.AudioManager; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.MediaPlayer.OnErrorListener; +import android.media.MediaPlayer.OnPreparedListener; +import android.media.browse.MediaBrowser; +import android.media.browse.MediaBrowser.MediaItem; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.net.Uri; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.WifiLock; +import android.os.Bundle; +import android.os.PowerManager; +import android.os.SystemClock; +import android.service.media.MediaBrowserService; + +import com.example.android.musicservicedemo.model.MusicProvider; +import com.example.android.musicservicedemo.utils.LogHelper; +import com.example.android.musicservicedemo.utils.MediaIDHelper; +import com.example.android.musicservicedemo.utils.QueueHelper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.example.android.musicservicedemo.utils.MediaIDHelper.MEDIA_ID_MUSICS_BY_GENRE; +import static com.example.android.musicservicedemo.utils.MediaIDHelper.MEDIA_ID_ROOT; +import static com.example.android.musicservicedemo.utils.MediaIDHelper.createBrowseCategoryMediaID; +import static com.example.android.musicservicedemo.utils.MediaIDHelper.extractBrowseCategoryFromMediaID; + +/** + * Main entry point for the Android Automobile integration. This class needs to: + * + * <ul> + * + * <li> Extend {@link android.service.media.MediaBrowserService}, implementing the media browsing + * related methods {@link android.service.media.MediaBrowserService#onGetRoot} and + * {@link android.service.media.MediaBrowserService#onLoadChildren}; + * <li> Start a new {@link android.media.session.MediaSession} and notify its parent with the + * session's token {@link android.service.media.MediaBrowserService#setSessionToken}; + * + * <li> Set a callback on the + * {@link android.media.session.MediaSession#setCallback(android.media.session.MediaSession.Callback)}. + * The callback will receive all the user's actions, like play, pause, etc; + * + * <li> Handle all the actual music playing using any method your app prefers (for example, + * {@link android.media.MediaPlayer}) + * + * <li> Update playbackState, "now playing" metadata and queue, using MediaSession proper methods + * {@link android.media.session.MediaSession#setPlaybackState(android.media.session.PlaybackState)} + * {@link android.media.session.MediaSession#setMetadata(android.media.MediaMetadata)} and + * {@link android.media.session.MediaSession#setQueue(java.util.List)}) + * + * <li> Be declared in AndroidManifest as an intent receiver for the action + * android.media.browse.MediaBrowserService + * + * <li> Declare a meta-data tag in AndroidManifest.xml linking to a xml resource + * with a <automotiveApp> root element. For a media app, this must include + * an <uses name="media"/> element as a child. + * For example, in AndroidManifest.xml: + * <meta-data android:name="com.google.android.gms.car.application" + * android:resource="@xml/automotive_app_desc"/> + * And in res/values/automotive_app_desc.xml: + * <automotiveApp> + * <uses name="media"/> + * </automotiveApp> + * + * </ul> + + * <p> + * Customization: + * + * <li> Add custom actions in the state passed to setPlaybackState(state) + * <li> Handle custom actions in the MediaSession.Callback.onCustomAction + * <li> Use UI theme primaryColor to set the player color + * + * @see <a href="README.txt">README.txt</a> for more details. + * + */ + +public class MusicService extends MediaBrowserService implements OnPreparedListener, + OnCompletionListener, OnErrorListener, AudioManager.OnAudioFocusChangeListener { + + private static final String TAG = "MusicService"; + + // Action to thumbs up a media item + private static final String CUSTOM_ACTION_THUMBS_UP = "thumbs_up"; + + // The volume we set the media player to when we lose audio focus, but are + // allowed to reduce the volume instead of stopping playback. + public static final float VOLUME_DUCK = 0.2f; + + // The volume we set the media player when we have audio focus. + public static final float VOLUME_NORMAL = 1.0f; + public static final String ANDROID_AUTO_PACKAGE_NAME = "com.google.android.projection.gearhead"; + public static final String ANDROID_AUTO_EMULATOR_PACKAGE_NAME = "com.example.android.media"; + + // Music catalog manager + private MusicProvider mMusicProvider; + + private MediaSession mSession; + private MediaPlayer mMediaPlayer; + + // "Now playing" queue: + private List<MediaSession.QueueItem> mPlayingQueue; + private int mCurrentIndexOnQueue; + + // Current local media player state + private int mState = PlaybackState.STATE_NONE; + + // Wifi lock that we hold when streaming files from the internet, in order + // to prevent the device from shutting off the Wifi radio + private WifiLock mWifiLock; + + private MediaNotification mMediaNotification; + + enum AudioFocus { + NoFocusNoDuck, // we don't have audio focus, and can't duck + NoFocusCanDuck, // we don't have focus, but can play at a low volume + // ("ducking") + Focused // we have full audio focus + } + + // Type of audio focus we have: + private AudioFocus mAudioFocus = AudioFocus.NoFocusNoDuck; + private AudioManager mAudioManager; + + // Indicates if we should start playing immediately after we gain focus. + private boolean mPlayOnFocusGain; + + + /* + * (non-Javadoc) + * @see android.app.Service#onCreate() + */ + @Override + public void onCreate() { + super.onCreate(); + LogHelper.d(TAG, "onCreate"); + + mPlayingQueue = new ArrayList<>(); + + // Create the Wifi lock (this does not acquire the lock, this just creates it) + mWifiLock = ((WifiManager) getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL, "MusicDemo_lock"); + + // Create the music catalog metadata provider + mMusicProvider = new MusicProvider(); + mMusicProvider.retrieveMedia(new MusicProvider.Callback() { + @Override + public void onMusicCatalogReady(boolean success) { + mState = success ? PlaybackState.STATE_STOPPED : PlaybackState.STATE_ERROR; + } + }); + + mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + + // Start a new MediaSession + mSession = new MediaSession(this, "MusicService"); + setSessionToken(mSession.getSessionToken()); + mSession.setCallback(new MediaSessionCallback()); + updatePlaybackState(null); + + mMediaNotification = new MediaNotification(this); + } + + /* + * (non-Javadoc) + * @see android.app.Service#onDestroy() + */ + @Override + public void onDestroy() { + LogHelper.d(TAG, "onDestroy"); + + // Service is being killed, so make sure we release our resources + handleStopRequest(null); + + // In particular, always release the MediaSession to clean up resources + // and notify associated MediaController(s). + mSession.release(); + } + + + // ********* MediaBrowserService methods: + + @Override + public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) { + LogHelper.d(TAG, "OnGetRoot: clientPackageName=" + clientPackageName, + "; clientUid=" + clientUid + " ; rootHints=", rootHints); + // To ensure you are not allowing any arbitrary app to browse your app's contents, you + // need to check the origin: + if (!ANDROID_AUTO_PACKAGE_NAME.equals(clientPackageName) && + !ANDROID_AUTO_EMULATOR_PACKAGE_NAME.equals(clientPackageName)) { + // If the request comes from an untrusted package, return null. No further calls will + // be made to other media browsing methods. + LogHelper.w(TAG, "OnGetRoot: IGNORING request from untrusted package " + clientPackageName); + return null; + } + return new BrowserRoot(MEDIA_ID_ROOT, null); + } + + @Override + public void onLoadChildren(final String parentMediaId, final Result<List<MediaItem>> result) { + if (!mMusicProvider.isInitialized()) { + // Use result.detach to allow calling result.sendResult from another thread: + result.detach(); + + mMusicProvider.retrieveMedia(new MusicProvider.Callback() { + @Override + public void onMusicCatalogReady(boolean success) { + if (success) { + loadChildrenImpl(parentMediaId, result); + } else { + updatePlaybackState(getString(R.string.error_no_metadata)); + result.sendResult(new ArrayList<MediaItem>()); + } + } + }); + + } else { + // If our music catalog is already loaded/cached, load them into result immediately + loadChildrenImpl(parentMediaId, result); + } + } + + /** + * Actual implementation of onLoadChildren that assumes that MusicProvider is already + * initialized. + */ + private void loadChildrenImpl(final String parentMediaId, + final Result<List<MediaBrowser.MediaItem>> result) { + LogHelper.d(TAG, "OnLoadChildren: parentMediaId=", parentMediaId); + + List<MediaBrowser.MediaItem> mediaItems = new ArrayList<>(); + + if (MEDIA_ID_ROOT.equals(parentMediaId)) { + LogHelper.d(TAG, "OnLoadChildren.ROOT"); + mediaItems.add(new MediaBrowser.MediaItem( + new MediaDescription.Builder() + .setMediaId(MEDIA_ID_MUSICS_BY_GENRE) + .setTitle(getString(R.string.browse_genres)) + .setIconUri(Uri.parse("android.resource://com.example.android.musicservicedemo/drawable/ic_by_genre")) + .setSubtitle(getString(R.string.browse_genre_subtitle)) + .build(), MediaBrowser.MediaItem.FLAG_BROWSABLE + )); + + } else if (MEDIA_ID_MUSICS_BY_GENRE.equals(parentMediaId)) { + LogHelper.d(TAG, "OnLoadChildren.GENRES"); + for (String genre: mMusicProvider.getGenres()) { + MediaBrowser.MediaItem item = new MediaBrowser.MediaItem( + new MediaDescription.Builder() + .setMediaId(createBrowseCategoryMediaID(MEDIA_ID_MUSICS_BY_GENRE, genre)) + .setTitle(genre) + .setSubtitle(getString(R.string.browse_musics_by_genre_subtitle, genre)) + .build(), MediaBrowser.MediaItem.FLAG_BROWSABLE + ); + mediaItems.add(item); + } + + } else if (parentMediaId.startsWith(MEDIA_ID_MUSICS_BY_GENRE)) { + String genre = extractBrowseCategoryFromMediaID(parentMediaId)[1]; + LogHelper.d(TAG, "OnLoadChildren.SONGS_BY_GENRE genre=", genre); + for (MediaMetadata track: mMusicProvider.getMusicsByGenre(genre)) { + // Since mediaMetadata fields are immutable, we need to create a copy, so we + // can set a hierarchy-aware mediaID. We will need to know the media hierarchy + // when we get a onPlayFromMusicID call, so we can create the proper queue based + // on where the music was selected from (by artist, by genre, random, etc) + String hierarchyAwareMediaID = MediaIDHelper.createTrackMediaID( + MEDIA_ID_MUSICS_BY_GENRE, genre, track); + MediaMetadata trackCopy = new MediaMetadata.Builder(track) + .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, hierarchyAwareMediaID) + .build(); + MediaBrowser.MediaItem bItem = new MediaBrowser.MediaItem( + trackCopy.getDescription(), MediaItem.FLAG_PLAYABLE); + mediaItems.add(bItem); + } + } else { + LogHelper.w(TAG, "Skipping unmatched parentMediaId: ", parentMediaId); + } + result.sendResult(mediaItems); + } + + + + // ********* MediaSession.Callback implementation: + + private final class MediaSessionCallback extends MediaSession.Callback { + @Override + public void onPlay() { + LogHelper.d(TAG, "play"); + + if (mPlayingQueue == null || mPlayingQueue.isEmpty()) { + mPlayingQueue = QueueHelper.getRandomQueue(mMusicProvider); + mSession.setQueue(mPlayingQueue); + mSession.setQueueTitle(getString(R.string.random_queue_title)); + // start playing from the beginning of the queue + mCurrentIndexOnQueue = 0; + } + + if (mPlayingQueue != null && !mPlayingQueue.isEmpty()) { + handlePlayRequest(); + } + } + + @Override + public void onSkipToQueueItem(long queueId) { + LogHelper.d(TAG, "OnSkipToQueueItem:" + queueId); + if (mPlayingQueue != null && !mPlayingQueue.isEmpty()) { + + // set the current index on queue from the music Id: + mCurrentIndexOnQueue = QueueHelper.getMusicIndexOnQueue(mPlayingQueue, queueId); + + // play the music + handlePlayRequest(); + } + } + + @Override + public void onPlayFromMediaId(String mediaId, Bundle extras) { + LogHelper.d(TAG, "playFromMediaId mediaId:", mediaId, " extras=", extras); + + // The mediaId used here is not the unique musicId. This one comes from the + // MediaBrowser, and is actually a "hierarchy-aware mediaID": a concatenation of + // the hierarchy in MediaBrowser and the actual unique musicID. This is necessary + // so we can build the correct playing queue, based on where the track was + // selected from. + mPlayingQueue = QueueHelper.getPlayingQueue(mediaId, mMusicProvider); + mSession.setQueue(mPlayingQueue); + String queueTitle = getString(R.string.browse_musics_by_genre_subtitle, + MediaIDHelper.extractBrowseCategoryValueFromMediaID(mediaId)); + mSession.setQueueTitle(queueTitle); + + if (mPlayingQueue != null && !mPlayingQueue.isEmpty()) { + String uniqueMusicID = MediaIDHelper.extractMusicIDFromMediaID(mediaId); + // set the current index on queue from the music Id: + mCurrentIndexOnQueue = QueueHelper.getMusicIndexOnQueue( + mPlayingQueue, uniqueMusicID); + + // play the music + handlePlayRequest(); + } + } + + @Override + public void onPause() { + LogHelper.d(TAG, "pause. current state=" + mState); + handlePauseRequest(); + } + + @Override + public void onStop() { + LogHelper.d(TAG, "stop. current state=" + mState); + handleStopRequest(null); + } + + @Override + public void onSkipToNext() { + LogHelper.d(TAG, "skipToNext"); + mCurrentIndexOnQueue++; + if (mPlayingQueue != null && mCurrentIndexOnQueue >= mPlayingQueue.size()) { + mCurrentIndexOnQueue = 0; + } + if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { + mState = PlaybackState.STATE_PLAYING; + handlePlayRequest(); + } else { + LogHelper.e(TAG, "skipToNext: cannot skip to next. next Index=" + + mCurrentIndexOnQueue + " queue length=" + + (mPlayingQueue == null ? "null" : mPlayingQueue.size())); + handleStopRequest("Cannot skip"); + } + } + + @Override + public void onSkipToPrevious() { + LogHelper.d(TAG, "skipToPrevious"); + + mCurrentIndexOnQueue--; + if (mPlayingQueue != null && mCurrentIndexOnQueue < 0) { + // This sample's behavior: skipping to previous when in first song restarts the + // first song. + mCurrentIndexOnQueue = 0; + } + if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { + mState = PlaybackState.STATE_PLAYING; + handlePlayRequest(); + } else { + LogHelper.e(TAG, "skipToPrevious: cannot skip to previous. previous Index=" + + mCurrentIndexOnQueue + " queue length=" + + (mPlayingQueue == null ? "null" : mPlayingQueue.size())); + handleStopRequest("Cannot skip"); + } + } + + @Override + public void onCustomAction(String action, Bundle extras) { + if (CUSTOM_ACTION_THUMBS_UP.equals(action)) { + LogHelper.i(TAG, "onCustomAction: favorite for current track"); + MediaMetadata track = getCurrentPlayingMusic(); + if (track != null) { + String mediaId = track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); + mMusicProvider.setFavorite(mediaId, !mMusicProvider.isFavorite(mediaId)); + } + updatePlaybackState(null); + } else { + LogHelper.e(TAG, "Unsupported action: ", action); + } + + } + + @Override + public void onPlayFromSearch(String query, Bundle extras) { + LogHelper.d(TAG, "playFromSearch query=", query); + + mPlayingQueue = QueueHelper.getPlayingQueueFromSearch(query, mMusicProvider); + LogHelper.d(TAG, "playFromSearch playqueue.length=" + mPlayingQueue.size()); + mSession.setQueue(mPlayingQueue); + + if (mPlayingQueue != null && !mPlayingQueue.isEmpty()) { + + // start playing from the beginning of the queue + mCurrentIndexOnQueue = 0; + + handlePlayRequest(); + } + } + } + + + + // ********* MediaPlayer listeners: + + /* + * Called when media player is done playing current song. + * @see android.media.MediaPlayer.OnCompletionListener + */ + @Override + public void onCompletion(MediaPlayer player) { + LogHelper.d(TAG, "onCompletion from MediaPlayer"); + // The media player finished playing the current song, so we go ahead + // and start the next. + if (mPlayingQueue != null && !mPlayingQueue.isEmpty()) { + // In this sample, we restart the playing queue when it gets to the end: + mCurrentIndexOnQueue++; + if (mCurrentIndexOnQueue >= mPlayingQueue.size()) { + mCurrentIndexOnQueue = 0; + } + handlePlayRequest(); + } else { + // If there is nothing to play, we stop and release the resources: + handleStopRequest(null); + } + } + + /* + * Called when media player is done preparing. + * @see android.media.MediaPlayer.OnPreparedListener + */ + @Override + public void onPrepared(MediaPlayer player) { + LogHelper.d(TAG, "onPrepared from MediaPlayer"); + // The media player is done preparing. That means we can start playing if we + // have audio focus. + configMediaPlayerState(); + } + + /** + * Called when there's an error playing media. When this happens, the media + * player goes to the Error state. We warn the user about the error and + * reset the media player. + * + * @see android.media.MediaPlayer.OnErrorListener + */ + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + LogHelper.e(TAG, "Media player error: what=" + what + ", extra=" + extra); + handleStopRequest("MediaPlayer error " + what + " (" + extra + ")"); + return true; // true indicates we handled the error + } + + + + + // ********* OnAudioFocusChangeListener listener: + + + /** + * Called by AudioManager on audio focus changes. + */ + @Override + public void onAudioFocusChange(int focusChange) { + LogHelper.d(TAG, "onAudioFocusChange. focusChange=" + focusChange); + if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + // We have gained focus: + mAudioFocus = AudioFocus.Focused; + + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS || + focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || + focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + // We have lost focus. If we can duck (low playback volume), we can keep playing. + // Otherwise, we need to pause the playback. + boolean canDuck = focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK; + mAudioFocus = canDuck ? AudioFocus.NoFocusCanDuck : AudioFocus.NoFocusNoDuck; + + // If we are playing, we need to reset media player by calling configMediaPlayerState + // with mAudioFocus properly set. + if (mState == PlaybackState.STATE_PLAYING && !canDuck) { + // If we don't have audio focus and can't duck, we save the information that + // we were playing, so that we can resume playback once we get the focus back. + mPlayOnFocusGain = true; + } + } else { + LogHelper.e(TAG, "onAudioFocusChange: Ignoring unsupported focusChange: " + focusChange); + } + + configMediaPlayerState(); + } + + + + // ********* private methods: + + /** + * Handle a request to play music + */ + private void handlePlayRequest() { + LogHelper.d(TAG, "handlePlayRequest: mState=" + mState); + + mPlayOnFocusGain = true; + tryToGetAudioFocus(); + + if (!mSession.isActive()) { + mSession.setActive(true); + } + + // actually play the song + if (mState == PlaybackState.STATE_PAUSED) { + // If we're paused, just continue playback and restore the + // 'foreground service' state. + configMediaPlayerState(); + } else { + // If we're stopped or playing a song, + // just go ahead to the new song and (re)start playing + playCurrentSong(); + } + } + + + /** + * Handle a request to pause music + */ + private void handlePauseRequest() { + LogHelper.d(TAG, "handlePauseRequest: mState=" + mState); + + if (mState == PlaybackState.STATE_PLAYING) { + // Pause media player and cancel the 'foreground service' state. + mState = PlaybackState.STATE_PAUSED; + if (mMediaPlayer.isPlaying()) { + mMediaPlayer.pause(); + } + // while paused, retain the MediaPlayer but give up audio focus + relaxResources(false); + giveUpAudioFocus(); + } + updatePlaybackState(null); + } + + /** + * Handle a request to stop music + */ + private void handleStopRequest(String withError) { + LogHelper.d(TAG, "handleStopRequest: mState=" + mState + " error=", withError); + mState = PlaybackState.STATE_STOPPED; + + // let go of all resources... + relaxResources(true); + giveUpAudioFocus(); + updatePlaybackState(withError); + + mMediaNotification.stopNotification(); + + // service is no longer necessary. Will be started again if needed. + stopSelf(); + } + + /** + * Releases resources used by the service for playback. This includes the + * "foreground service" status, the wake locks and possibly the MediaPlayer. + * + * @param releaseMediaPlayer Indicates whether the Media Player should also + * be released or not + */ + private void relaxResources(boolean releaseMediaPlayer) { + LogHelper.d(TAG, "relaxResources. releaseMediaPlayer=" + releaseMediaPlayer); + // stop being a foreground service + stopForeground(true); + + // stop and release the Media Player, if it's available + if (releaseMediaPlayer && mMediaPlayer != null) { + mMediaPlayer.reset(); + mMediaPlayer.release(); + mMediaPlayer = null; + } + + // we can also release the Wifi lock, if we're holding it + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + } + + /** + * Reconfigures MediaPlayer according to audio focus settings and + * starts/restarts it. This method starts/restarts the MediaPlayer + * respecting the current audio focus state. So if we have focus, it will + * play normally; if we don't have focus, it will either leave the + * MediaPlayer paused or set it to a low volume, depending on what is + * allowed by the current focus settings. This method assumes mPlayer != + * null, so if you are calling it, you have to do so from a context where + * you are sure this is the case. + */ + private void configMediaPlayerState() { + LogHelper.d(TAG, "configAndStartMediaPlayer. mAudioFocus=" + mAudioFocus); + if (mAudioFocus == AudioFocus.NoFocusNoDuck) { + // If we don't have audio focus and can't duck, we have to pause, + if (mState == PlaybackState.STATE_PLAYING) { + handlePauseRequest(); + } + } else { // we have audio focus: + if (mAudioFocus == AudioFocus.NoFocusCanDuck) { + mMediaPlayer.setVolume(VOLUME_DUCK, VOLUME_DUCK); // we'll be relatively quiet + } else { + mMediaPlayer.setVolume(VOLUME_NORMAL, VOLUME_NORMAL); // we can be loud again + } + // If we were playing when we lost focus, we need to resume playing. + if (mPlayOnFocusGain) { + if (!mMediaPlayer.isPlaying()) { + LogHelper.d(TAG, "configAndStartMediaPlayer startMediaPlayer."); + mMediaPlayer.start(); + } + mPlayOnFocusGain = false; + mState = PlaybackState.STATE_PLAYING; + } + } + updatePlaybackState(null); + } + + /** + * Makes sure the media player exists and has been reset. This will create + * the media player if needed, or reset the existing media player if one + * already exists. + */ + private void createMediaPlayerIfNeeded() { + LogHelper.d(TAG, "createMediaPlayerIfNeeded. needed? " + (mMediaPlayer==null)); + if (mMediaPlayer == null) { + mMediaPlayer = new MediaPlayer(); + + // Make sure the media player will acquire a wake-lock while + // playing. If we don't do that, the CPU might go to sleep while the + // song is playing, causing playback to stop. + mMediaPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); + + // we want the media player to notify us when it's ready preparing, + // and when it's done playing: + mMediaPlayer.setOnPreparedListener(this); + mMediaPlayer.setOnCompletionListener(this); + mMediaPlayer.setOnErrorListener(this); + } else { + mMediaPlayer.reset(); + } + } + + /** + * Starts playing the current song in the playing queue. + */ + void playCurrentSong() { + MediaMetadata track = getCurrentPlayingMusic(); + if (track == null) { + LogHelper.e(TAG, "playSong: ignoring request to play next song, because cannot" + + " find it." + + " currentIndex=" + mCurrentIndexOnQueue + + " playQueue.size=" + (mPlayingQueue==null?"null": mPlayingQueue.size())); + return; + } + String source = track.getString(MusicProvider.CUSTOM_METADATA_TRACK_SOURCE); + LogHelper.d(TAG, "playSong: current (" + mCurrentIndexOnQueue + ") in playingQueue. " + + " musicId=" + track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID) + + " source=" + source); + + mState = PlaybackState.STATE_STOPPED; + relaxResources(false); // release everything except MediaPlayer + + try { + createMediaPlayerIfNeeded(); + + mState = PlaybackState.STATE_BUFFERING; + + mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + LogHelper.d(TAG, "****** playCurrentSong: about to call setDataSource. If no" + + "'finished' log message shows up right after this, it's because the media " + + "player is stuck in a deadlock. This is a known issue. In the meantime, you " + + "will need to restart the device."); + try { + mMediaPlayer.setDataSource(source); + } finally { + LogHelper.d(TAG, "****** playCurrentSong: setDataSource finished, no deadlock :-)"); + } + + // Starts preparing the media player in the background. When + // it's done, it will call our OnPreparedListener (that is, + // the onPrepared() method on this class, since we set the + // listener to 'this'). Until the media player is prepared, + // we *cannot* call start() on it! + mMediaPlayer.prepareAsync(); + + // If we are streaming from the internet, we want to hold a + // Wifi lock, which prevents the Wifi radio from going to + // sleep while the song is playing. + mWifiLock.acquire(); + + updatePlaybackState(null); + updateMetadata(); + + } catch (IOException ex) { + LogHelper.e(TAG, ex, "IOException playing song"); + updatePlaybackState(ex.getMessage()); + } + } + + + + private void updateMetadata() { + if (!QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { + LogHelper.e(TAG, "Can't retrieve current metadata."); + mState = PlaybackState.STATE_ERROR; + updatePlaybackState(getResources().getString(R.string.error_no_metadata)); + return; + } + MediaSession.QueueItem queueItem = mPlayingQueue.get(mCurrentIndexOnQueue); + String mediaId = queueItem.getDescription().getMediaId(); + MediaMetadata track = mMusicProvider.getMusic(mediaId); + String trackId = track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); + if (!mediaId.equals(trackId)) { + throw new IllegalStateException("track ID (" + trackId + ") " + + "should match mediaId (" + mediaId + ")"); + } + LogHelper.d(TAG, "Updating metadata for MusicID= " + mediaId); + mSession.setMetadata(track); + } + + + /** + * Update the current media player state, optionally showing an error message. + * + * @param error if not null, error message to present to the user. + * + */ + private void updatePlaybackState(String error) { + LogHelper.d(TAG, "updatePlaybackState, setting session playback state to " + mState); + long position = PlaybackState.PLAYBACK_POSITION_UNKNOWN; + if (mMediaPlayer != null && mMediaPlayer.isPlaying()) { + position = mMediaPlayer.getCurrentPosition(); + } + PlaybackState.Builder stateBuilder = new PlaybackState.Builder() + .setActions(getAvailableActions()); + + setCustomAction(stateBuilder); + + // If there is an error message, send it to the playback state: + if (error != null) { + // Error states are really only supposed to be used for errors that cause playback to + // stop unexpectedly and persist until the user takes action to fix it. + stateBuilder.setErrorMessage(error); + mState = PlaybackState.STATE_ERROR; + } + stateBuilder.setState(mState, position, 1.0f, SystemClock.elapsedRealtime()); + + mSession.setPlaybackState(stateBuilder.build()); + + if (mState == PlaybackState.STATE_PLAYING || mState == PlaybackState.STATE_PAUSED) { + mMediaNotification.startNotification(); + } + } + + private void setCustomAction(PlaybackState.Builder stateBuilder) { + MediaMetadata currentMusic = getCurrentPlayingMusic(); + if (currentMusic != null) { + // Set appropriate "Favorite" icon on Custom action: + String mediaId = currentMusic.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); + int favoriteIcon = android.R.drawable.btn_star_big_off; + if (mMusicProvider.isFavorite(mediaId)) { + favoriteIcon = android.R.drawable.btn_star_big_on; + } + LogHelper.d(TAG, "updatePlaybackState, setting Favorite custom action of music ", + mediaId, " current favorite=", mMusicProvider.isFavorite(mediaId)); + stateBuilder.addCustomAction(CUSTOM_ACTION_THUMBS_UP, getString(R.string.favorite), + favoriteIcon); + } + } + + private long getAvailableActions() { + long actions = PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PLAY_FROM_MEDIA_ID | + PlaybackState.ACTION_PLAY_FROM_SEARCH; + if (mPlayingQueue == null || mPlayingQueue.isEmpty()) { + return actions; + } + if (mState == PlaybackState.STATE_PLAYING) { + actions |= PlaybackState.ACTION_PAUSE; + } + if (mCurrentIndexOnQueue > 0) { + actions |= PlaybackState.ACTION_SKIP_TO_PREVIOUS; + } + if (mCurrentIndexOnQueue < mPlayingQueue.size() - 1) { + actions |= PlaybackState.ACTION_SKIP_TO_NEXT; + } + return actions; + } + + private MediaMetadata getCurrentPlayingMusic() { + if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { + MediaSession.QueueItem item = mPlayingQueue.get(mCurrentIndexOnQueue); + if (item != null) { + LogHelper.d(TAG, "getCurrentPlayingMusic for musicId=", + item.getDescription().getMediaId()); + return mMusicProvider.getMusic(item.getDescription().getMediaId()); + } + } + return null; + } + + /** + * Try to get the system audio focus. + */ + void tryToGetAudioFocus() { + LogHelper.d(TAG, "tryToGetAudioFocus"); + if (mAudioFocus != AudioFocus.Focused) { + int result = mAudioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + mAudioFocus = AudioFocus.Focused; + } + } + + } + + /** + * Give up the audio focus. + */ + void giveUpAudioFocus() { + LogHelper.d(TAG, "giveUpAudioFocus"); + if (mAudioFocus == AudioFocus.Focused) { + if (mAudioManager.abandonAudioFocus(this) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + mAudioFocus = AudioFocus.NoFocusNoDuck; + } + } + } +} diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/model/MusicProvider.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/model/MusicProvider.java new file mode 100644 index 0000000..dd89c2d --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/model/MusicProvider.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2014 Google Inc. All Rights Reserved. + * + * 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.example.android.musicservicedemo.model; + +import android.media.MediaMetadata; +import android.os.AsyncTask; + +import com.example.android.musicservicedemo.utils.LogHelper; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Utility class to get a list of MusicTrack's based on a server-side JSON + * configuration. + */ +public class MusicProvider { + + private static final String TAG = "MusicProvider"; + + private static final String CATALOG_URL = "http://storage.googleapis.com/automotive-media/music.json"; + + public static final String CUSTOM_METADATA_TRACK_SOURCE = "__SOURCE__"; + + private static String JSON_MUSIC = "music"; + private static String JSON_TITLE = "title"; + private static String JSON_ALBUM = "album"; + private static String JSON_ARTIST = "artist"; + private static String JSON_GENRE = "genre"; + private static String JSON_SOURCE = "source"; + private static String JSON_IMAGE = "image"; + private static String JSON_TRACK_NUMBER = "trackNumber"; + private static String JSON_TOTAL_TRACK_COUNT = "totalTrackCount"; + private static String JSON_DURATION = "duration"; + + private final ReentrantLock initializationLock = new ReentrantLock(); + + // Categorized caches for music track data: + private final HashMap<String, List<MediaMetadata>> mMusicListByGenre; + private final HashMap<String, MediaMetadata> mMusicListById; + + private final HashSet<String> mFavoriteTracks; + + enum State { + NON_INITIALIZED, INITIALIZING, INITIALIZED; + } + + private State mCurrentState = State.NON_INITIALIZED; + + + public interface Callback { + void onMusicCatalogReady(boolean success); + } + + public MusicProvider() { + mMusicListByGenre = new HashMap<>(); + mMusicListById = new HashMap<>(); + mFavoriteTracks = new HashSet<>(); + } + + /** + * Get an iterator over the list of genres + * + * @return + */ + public Iterable<String> getGenres() { + if (mCurrentState != State.INITIALIZED) { + return new ArrayList<String>(0); + } + return mMusicListByGenre.keySet(); + } + + /** + * Get music tracks of the given genre + * + * @return + */ + public Iterable<MediaMetadata> getMusicsByGenre(String genre) { + if (mCurrentState != State.INITIALIZED || !mMusicListByGenre.containsKey(genre)) { + return new ArrayList<MediaMetadata>(); + } + return mMusicListByGenre.get(genre); + } + + /** + * Very basic implementation of a search that filter music tracks which title containing + * the given query. + * + * @return + */ + public Iterable<MediaMetadata> searchMusics(String titleQuery) { + ArrayList<MediaMetadata> result = new ArrayList<>(); + if (mCurrentState != State.INITIALIZED) { + return result; + } + titleQuery = titleQuery.toLowerCase(); + for (MediaMetadata track: mMusicListById.values()) { + if (track.getString(MediaMetadata.METADATA_KEY_TITLE).toLowerCase() + .contains(titleQuery)) { + result.add(track); + } + } + return result; + } + + public MediaMetadata getMusic(String mediaId) { + return mMusicListById.get(mediaId); + } + + public void setFavorite(String mediaId, boolean favorite) { + if (favorite) { + mFavoriteTracks.add(mediaId); + } else { + mFavoriteTracks.remove(mediaId); + } + } + + public boolean isFavorite(String musicId) { + return mFavoriteTracks.contains(musicId); + } + + public boolean isInitialized() { + return mCurrentState == State.INITIALIZED; + } + + /** + * Get the list of music tracks from a server and caches the track information + * for future reference, keying tracks by mediaId and grouping by genre. + * + * @return + */ + public void retrieveMedia(final Callback callback) { + + if (mCurrentState == State.INITIALIZED) { + // Nothing to do, execute callback immediately + callback.onMusicCatalogReady(true); + return; + } + + // Asynchronously load the music catalog in a separate thread + new AsyncTask() { + @Override + protected Object doInBackground(Object[] objects) { + retrieveMediaAsync(callback); + return null; + } + }.execute(); + } + + private void retrieveMediaAsync(Callback callback) { + initializationLock.lock(); + + try { + if (mCurrentState == State.NON_INITIALIZED) { + mCurrentState = State.INITIALIZING; + + int slashPos = CATALOG_URL.lastIndexOf('/'); + String path = CATALOG_URL.substring(0, slashPos + 1); + JSONObject jsonObj = parseUrl(CATALOG_URL); + + JSONArray tracks = jsonObj.getJSONArray(JSON_MUSIC); + if (tracks != null) { + for (int j = 0; j < tracks.length(); j++) { + MediaMetadata item = buildFromJSON(tracks.getJSONObject(j), path); + String genre = item.getString(MediaMetadata.METADATA_KEY_GENRE); + List<MediaMetadata> list = mMusicListByGenre.get(genre); + if (list == null) { + list = new ArrayList<>(); + } + list.add(item); + mMusicListByGenre.put(genre, list); + mMusicListById.put(item.getString(MediaMetadata.METADATA_KEY_MEDIA_ID), + item); + } + } + mCurrentState = State.INITIALIZED; + } + } catch (RuntimeException | JSONException e) { + LogHelper.e(TAG, e, "Could not retrieve music list"); + } finally { + if (mCurrentState != State.INITIALIZED) { + // Something bad happened, so we reset state to NON_INITIALIZED to allow + // retries (eg if the network connection is temporary unavailable) + mCurrentState = State.NON_INITIALIZED; + } + initializationLock.unlock(); + if (callback != null) { + callback.onMusicCatalogReady(mCurrentState == State.INITIALIZED); + } + } + } + + private MediaMetadata buildFromJSON(JSONObject json, String basePath) throws JSONException { + String title = json.getString(JSON_TITLE); + String album = json.getString(JSON_ALBUM); + String artist = json.getString(JSON_ARTIST); + String genre = json.getString(JSON_GENRE); + String source = json.getString(JSON_SOURCE); + String iconUrl = json.getString(JSON_IMAGE); + int trackNumber = json.getInt(JSON_TRACK_NUMBER); + int totalTrackCount = json.getInt(JSON_TOTAL_TRACK_COUNT); + int duration = json.getInt(JSON_DURATION) * 1000; // ms + + LogHelper.d(TAG, "Found music track: ", json); + + // Media is stored relative to JSON file + if (!source.startsWith("http")) { + source = basePath + source; + } + if (!iconUrl.startsWith("http")) { + iconUrl = basePath + iconUrl; + } + // Since we don't have a unique ID in the server, we fake one using the hashcode of + // the music source. In a real world app, this could come from the server. + String id = String.valueOf(source.hashCode()); + + // Adding the music source to the MediaMetadata (and consequently using it in the + // mediaSession.setMetadata) is not a good idea for a real world music app, because + // the session metadata can be accessed by notification listeners. This is done in this + // sample for convenience only. + return new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, id) + .putString(CUSTOM_METADATA_TRACK_SOURCE, source) + .putString(MediaMetadata.METADATA_KEY_ALBUM, album) + .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + .putLong(MediaMetadata.METADATA_KEY_DURATION, duration) + .putString(MediaMetadata.METADATA_KEY_GENRE, genre) + .putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, iconUrl) + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER, trackNumber) + .putLong(MediaMetadata.METADATA_KEY_NUM_TRACKS, totalTrackCount) + .build(); + } + + /** + * Download a JSON file from a server, parse the content and return the JSON + * object. + * + * @param urlString + * @return + */ + private JSONObject parseUrl(String urlString) { + InputStream is = null; + try { + java.net.URL url = new java.net.URL(urlString); + URLConnection urlConnection = url.openConnection(); + is = new BufferedInputStream(urlConnection.getInputStream()); + BufferedReader reader = new BufferedReader(new InputStreamReader( + urlConnection.getInputStream(), "iso-8859-1"), 8); + StringBuilder sb = new StringBuilder(); + String line = null; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return new JSONObject(sb.toString()); + } catch (Exception e) { + LogHelper.e(TAG, "Failed to parse the json for media list", e); + return null; + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // ignore + } + } + } + } +}
\ No newline at end of file diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/BitmapHelper.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/BitmapHelper.java new file mode 100644 index 0000000..c743262 --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/BitmapHelper.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014 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.example.android.musicservicedemo.utils; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class BitmapHelper { + + // Bitmap size for album art in media notifications when there are more than 3 playback actions + public static final int MEDIA_ART_SMALL_WIDTH=64; + public static final int MEDIA_ART_SMALL_HEIGHT=64; + + // Bitmap size for album art in media notifications when there are no more than 3 playback actions + public static final int MEDIA_ART_BIG_WIDTH=128; + public static final int MEDIA_ART_BIG_HEIGHT=128; + + public static final Bitmap scaleBitmap(int targetW, int targetH, InputStream is) { + // Get the dimensions of the bitmap + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + bmOptions.inJustDecodeBounds = true; + BitmapFactory.decodeStream(is, null, bmOptions); + int actualW = bmOptions.outWidth; + int actualH = bmOptions.outHeight; + + // Determine how much to scale down the image + int scaleFactor = Math.min(actualW/targetW, actualH/targetH); + + // Decode the image file into a Bitmap sized to fill the View + bmOptions.inJustDecodeBounds = false; + bmOptions.inSampleSize = scaleFactor; + + Bitmap bitmap = BitmapFactory.decodeStream(is, null, bmOptions); + return bitmap; + } + + public static final Bitmap scaleBitmap(int scaleFactor, InputStream is) { + // Get the dimensions of the bitmap + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + + // Decode the image file into a Bitmap sized to fill the View + bmOptions.inJustDecodeBounds = false; + bmOptions.inSampleSize = scaleFactor; + + Bitmap bitmap = BitmapFactory.decodeStream(is, null, bmOptions); + return bitmap; + } + + public static final int findScaleFactor(int targetW, int targetH, InputStream is) { + // Get the dimensions of the bitmap + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + bmOptions.inJustDecodeBounds = true; + BitmapFactory.decodeStream(is, null, bmOptions); + int actualW = bmOptions.outWidth; + int actualH = bmOptions.outHeight; + + // Determine how much to scale down the image + return Math.min(actualW/targetW, actualH/targetH); + } + + public static final Bitmap fetchAndRescaleBitmap(String uri, int width, int height) + throws IOException { + URL url = new URL(uri); + HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection(); + httpConnection.setDoInput(true); + httpConnection.connect(); + InputStream inputStream = httpConnection.getInputStream(); + int scaleFactor = findScaleFactor(width, height, inputStream); + + httpConnection = (HttpURLConnection) url.openConnection(); + httpConnection.setDoInput(true); + httpConnection.connect(); + inputStream = httpConnection.getInputStream(); + Bitmap bitmap = scaleBitmap(scaleFactor, inputStream); + return bitmap; + } + +} diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/LogHelper.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/LogHelper.java new file mode 100644 index 0000000..4c757f7 --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/LogHelper.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014 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.example.android.musicservicedemo.utils; + +import android.util.Log; + +public class LogHelper { + public final static void v(String tag, Object... messages) { + log(tag, Log.VERBOSE, null, messages); + } + + public final static void d(String tag, Object... messages) { + log(tag, Log.DEBUG, null, messages); + } + + public final static void i(String tag, Object... messages) { + log(tag, Log.INFO, null, messages); + } + + public final static void w(String tag, Object... messages) { + log(tag, Log.WARN, null, messages); + } + + public final static void w(String tag, Throwable t, Object... messages) { + log(tag, Log.WARN, t, messages); + } + + public final static void e(String tag, Object... messages) { + log(tag, Log.ERROR, null, messages); + } + + public final static void e(String tag, Throwable t, Object... messages) { + log(tag, Log.ERROR, t, messages); + } + + public final static void log(String tag, int level, Throwable t, Object... messages) { + if (messages != null && Log.isLoggable(tag, level)) { + String message = null; + if (messages.length == 1) { + message = messages[0] == null ? null : messages[0].toString(); + } else { + StringBuilder sb = new StringBuilder(); + for (Object m: messages) { + sb.append(m); + } + message = sb.toString(); + } + Log.d(tag, message, t); + } + } + +} diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/MediaIDHelper.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/MediaIDHelper.java new file mode 100644 index 0000000..2406886 --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/MediaIDHelper.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2014 Google Inc. All Rights Reserved. + * + * 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.example.android.musicservicedemo.utils; + +import android.media.MediaMetadata; + +/** + * Utility class to help on queue related tasks. + */ +public class MediaIDHelper { + + private static final String TAG = "MediaIDHelper"; + + // Media IDs used on browseable items of MediaBrowser + public static final String MEDIA_ID_ROOT = "__ROOT__"; + public static final String MEDIA_ID_MUSICS_BY_GENRE = "__BY_GENRE__"; + + public static final String createTrackMediaID(String categoryType, String categoryValue, + MediaMetadata track) { + // MediaIDs are of the form <categoryType>/<categoryValue>|<musicUniqueId>, to make it easy to + // find the category (like genre) that a music was selected from, so we + // can correctly build the playing queue. This is specially useful when + // one music can appear in more than one list, like "by genre -> genre_1" + // and "by artist -> artist_1". + return categoryType + "/" + categoryValue + "|" + + track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); + } + + public static final String createBrowseCategoryMediaID(String categoryType, String categoryValue) { + return categoryType + "/" + categoryValue; + } + + /** + * Extracts unique musicID from the mediaID. mediaID is, by this sample's convention, a + * concatenation of category (eg "by_genre"), categoryValue (eg "Classical") and unique + * musicID. This is necessary so we know where the user selected the music from, when the music + * exists in more than one music list, and thus we are able to correctly build the playing queue. + * + * @param musicID + * @return + */ + public static final String extractMusicIDFromMediaID(String musicID) { + String[] segments = musicID.split("\\|", 2); + return segments.length == 2 ? segments[1] : null; + } + + /** + * Extracts category and categoryValue from the mediaID. mediaID is, by this sample's + * convention, a concatenation of category (eg "by_genre"), categoryValue (eg "Classical") and + * mediaID. This is necessary so we know where the user selected the music from, when the music + * exists in more than one music list, and thus we are able to correctly build the playing queue. + * + * @param mediaID + * @return + */ + public static final String[] extractBrowseCategoryFromMediaID(String mediaID) { + if (mediaID.indexOf('|') >= 0) { + mediaID = mediaID.split("\\|")[0]; + } + if (mediaID.indexOf('/') == 0) { + return new String[]{mediaID, null}; + } else { + return mediaID.split("/", 2); + } + } + + public static final String extractBrowseCategoryValueFromMediaID(String mediaID) { + String[] categoryAndValue = extractBrowseCategoryFromMediaID(mediaID); + if (categoryAndValue != null && categoryAndValue.length == 2) { + return categoryAndValue[1]; + } + return null; + } +}
\ No newline at end of file diff --git a/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/QueueHelper.java b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/QueueHelper.java new file mode 100644 index 0000000..4dc7a96 --- /dev/null +++ b/MusicDemo/src/main/java/com/example/android/musicservicedemo/utils/QueueHelper.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2014 Google Inc. All Rights Reserved. + * + * 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.example.android.musicservicedemo.utils; + +import android.media.MediaMetadata; +import android.media.session.MediaSession; + +import com.example.android.musicservicedemo.model.MusicProvider; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static com.example.android.musicservicedemo.utils.MediaIDHelper.MEDIA_ID_MUSICS_BY_GENRE; + +/** + * Utility class to help on queue related tasks. + */ +public class QueueHelper { + + private static final String TAG = "QueueHelper"; + + public static final List<MediaSession.QueueItem> getPlayingQueue(String mediaId, + MusicProvider musicProvider) { + + // extract the category and unique music ID from the media ID: + String[] category = MediaIDHelper.extractBrowseCategoryFromMediaID(mediaId); + + // This sample only supports genre category. + if (!category[0].equals(MEDIA_ID_MUSICS_BY_GENRE) || category.length != 2) { + LogHelper.e(TAG, "Could not build a playing queue for this mediaId: ", mediaId); + return null; + } + + String categoryValue = category[1]; + LogHelper.e(TAG, "Creating playing queue for musics of genre ", categoryValue); + + List<MediaSession.QueueItem> queue = convertToQueue( + musicProvider.getMusicsByGenre(categoryValue)); + + return queue; + } + + public static final List<MediaSession.QueueItem> getPlayingQueueFromSearch(String query, + MusicProvider musicProvider) { + + LogHelper.e(TAG, "Creating playing queue for musics from search ", query); + + return convertToQueue(musicProvider.searchMusics(query)); + } + + + public static final int getMusicIndexOnQueue(Iterable<MediaSession.QueueItem> queue, + String mediaId) { + int index = 0; + for (MediaSession.QueueItem item: queue) { + if (mediaId.equals(item.getDescription().getMediaId())) { + return index; + } + index++; + } + return -1; + } + + public static final int getMusicIndexOnQueue(Iterable<MediaSession.QueueItem> queue, + long queueId) { + int index = 0; + for (MediaSession.QueueItem item: queue) { + if (queueId == item.getQueueId()) { + return index; + } + index++; + } + return -1; + } + + private static final List<MediaSession.QueueItem> convertToQueue( + Iterable<MediaMetadata> tracks) { + List<MediaSession.QueueItem> queue = new ArrayList<>(); + int count = 0; + for (MediaMetadata track : tracks) { + // We don't expect queues to change after created, so we use the item index as the + // queueId. Any other number unique in the queue would work. + MediaSession.QueueItem item = new MediaSession.QueueItem( + track.getDescription(), count++); + queue.add(item); + } + return queue; + + } + + /** + * Create a random queue. For simplicity sake, instead of a random queue, we create a + * queue using the first genre, + * + * @param musicProvider + * @return + */ + public static final List<MediaSession.QueueItem> getRandomQueue(MusicProvider musicProvider) { + Iterator<String> genres = musicProvider.getGenres().iterator(); + if (!genres.hasNext()) { + return new ArrayList<>(); + } + String genre = genres.next(); + Iterable<MediaMetadata> tracks = musicProvider.getMusicsByGenre(genre); + + return convertToQueue(tracks); + } + + + + public static final boolean isIndexPlayable(int index, List<MediaSession.QueueItem> queue) { + return (queue != null && index >= 0 && index < queue.size()); + } +}
\ No newline at end of file diff --git a/MusicDemo/src/main/res/drawable-hdpi/ic_launcher.png b/MusicDemo/src/main/res/drawable-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..47d6854 --- /dev/null +++ b/MusicDemo/src/main/res/drawable-hdpi/ic_launcher.png diff --git a/MusicDemo/src/main/res/drawable-hdpi/ic_notification.png b/MusicDemo/src/main/res/drawable-hdpi/ic_notification.png Binary files differnew file mode 100644 index 0000000..d8ea5a9 --- /dev/null +++ b/MusicDemo/src/main/res/drawable-hdpi/ic_notification.png diff --git a/MusicDemo/src/main/res/drawable-mdpi/ic_launcher.png b/MusicDemo/src/main/res/drawable-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..01b53fd --- /dev/null +++ b/MusicDemo/src/main/res/drawable-mdpi/ic_launcher.png diff --git a/MusicDemo/src/main/res/drawable-xhdpi/ic_launcher.png b/MusicDemo/src/main/res/drawable-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..af762f2 --- /dev/null +++ b/MusicDemo/src/main/res/drawable-xhdpi/ic_launcher.png diff --git a/MusicDemo/src/main/res/drawable-xxhdpi/ic_by_genre.png b/MusicDemo/src/main/res/drawable-xxhdpi/ic_by_genre.png Binary files differnew file mode 100644 index 0000000..da3b4a7 --- /dev/null +++ b/MusicDemo/src/main/res/drawable-xxhdpi/ic_by_genre.png diff --git a/MusicDemo/src/main/res/drawable-xxhdpi/ic_launcher.png b/MusicDemo/src/main/res/drawable-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..eef47aa --- /dev/null +++ b/MusicDemo/src/main/res/drawable-xxhdpi/ic_launcher.png diff --git a/MusicDemo/src/main/res/values-v21/styles.xml b/MusicDemo/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..6169d24 --- /dev/null +++ b/MusicDemo/src/main/res/values-v21/styles.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. +--> +<resources> + + <style name="AppBaseTheme" parent="Theme.AppCompat.Light"> + <!-- colorPrimary is used for Notification icon and bottom facet bar icons + and overflow actions --> + <item name="android:colorPrimary">@color/red</item> + + <!-- colorPrimaryDark is used for background --> + <item name="android:colorPrimaryDark">#990000</item> + + <!-- colorAccent is sparingly used for accents, like floating action button highlight, + progress on playbar--> + <item name="android:colorAccent">#0000FF</item> + + </style> + +</resources> diff --git a/MusicDemo/src/main/res/values/colors.xml b/MusicDemo/src/main/res/values/colors.xml new file mode 100644 index 0000000..6a5277e --- /dev/null +++ b/MusicDemo/src/main/res/values/colors.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<resources> + <color name="red">#ffff0000</color> +</resources> diff --git a/MusicDemo/src/main/res/values/strings.xml b/MusicDemo/src/main/res/values/strings.xml new file mode 100644 index 0000000..82e07b0 --- /dev/null +++ b/MusicDemo/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. + --> +<resources> + + <string name="app_name">Auto Music Demo</string> + <string name="favorite">Favorite</string> + <string name="error_no_metadata">Unable to retrieve metadata.</string> + <string name="browse_genres">Genres</string> + <string name="browse_genre_subtitle">Songs by genre</string> + <string name="browse_musics_by_genre_subtitle">%1$s songs</string> + <string name="random_queue_title">Random music</string> + <string name="error_cannot_skip">Cannot skip</string> + +</resources> diff --git a/MusicDemo/src/main/res/values/strings_notifications.xml b/MusicDemo/src/main/res/values/strings_notifications.xml new file mode 100644 index 0000000..f406ba6 --- /dev/null +++ b/MusicDemo/src/main/res/values/strings_notifications.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. + --> +<resources> + + <string name="label_pause">Pause</string> + <string name="label_play">Play</string> + <string name="label_previous">Previous</string> + <string name="label_next">Next</string> + <string name="error_empty_metadata">Empty metadata!</string> +</resources> diff --git a/MusicDemo/src/main/res/values/styles.xml b/MusicDemo/src/main/res/values/styles.xml new file mode 100644 index 0000000..507dc7b --- /dev/null +++ b/MusicDemo/src/main/res/values/styles.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. +--> +<resources> + + <style name="AppBaseTheme" parent="Theme.AppCompat.Light"></style> + + <style name="AppTheme" parent="AppBaseTheme"></style> + +</resources>
\ No newline at end of file diff --git a/MusicDemo/src/main/res/xml/automotive_app_desc.xml b/MusicDemo/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..a84750b --- /dev/null +++ b/MusicDemo/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. + --> +<automotiveApp> + <uses name="media"/> +</automotiveApp> |