/** * This class provides a MediaBrowser through a service. It exposes the media library to a browsing * client, through the onGetRoot and onLoadChildren methods. It also creates a MediaSession and * exposes it through its MediaSession.Token, which allows the client to create a MediaController * that connects to and send control commands to the MediaSession remotely. This is useful for user * interfaces that need to interact with your media session, like Android Auto. You can (should) * also use the same service from your app's UI, which gives a seamless playback experience to the * user. * * <p>To implement a MediaBrowserService, you need 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>In onCreate, 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>Declare and export the service in AndroidManifest with an intent receiver for the action * android.media.browse.MediaBrowserService * </ul> * * To make your app compatible with Android Auto, you also need to: * * <ul> * <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> * * @see <a href="README.md">README.md</a> for more details. */ public class MusicService extends MediaBrowserService implements Playback.Callback { // Extra on MediaSession that contains the Cast device name currently connected to public static final String EXTRA_CONNECTED_CAST = "com.example.android.uamp.CAST_NAME"; // The action of the incoming Intent indicating that it contains a command // to be executed (see {@link #onStartCommand}) public static final String ACTION_CMD = "com.example.android.uamp.ACTION_CMD"; // The key in the extras of the incoming Intent indicating the command that // should be executed (see {@link #onStartCommand}) public static final String CMD_NAME = "CMD_NAME"; // A value of a CMD_NAME key in the extras of the incoming Intent that // indicates that the music playback should be paused (see {@link #onStartCommand}) public static final String CMD_PAUSE = "CMD_PAUSE"; // A value of a CMD_NAME key that indicates that the music playback should switch // to local playback from cast playback. public static final String CMD_STOP_CASTING = "CMD_STOP_CASTING"; private static final String TAG = LogHelper.makeLogTag(MusicService.class); // Action to thumbs up a media item private static final String CUSTOM_ACTION_THUMBS_UP = "com.example.android.uamp.THUMBS_UP"; // Delay stopSelf by using a handler. private static final int STOP_DELAY = 30000; // Music catalog manager private MusicProvider mMusicProvider; private MediaSession mSession; // "Now playing" queue: private List<MediaSession.QueueItem> mPlayingQueue; private int mCurrentIndexOnQueue; // Current local media player state private MediaNotificationManager mMediaNotificationManager; // Indicates whether the service was started. private boolean mServiceStarted; private Bundle mSessionExtras; private DelayedStopHandler mDelayedStopHandler = new DelayedStopHandler(this); private Playback mPlayback; private MediaRouter mMediaRouter; private PackageValidator mPackageValidator; /** * Consumer responsible for switching the Playback instances depending on whether it is connected * to a remote player. */ private final VideoCastConsumerImpl mCastConsumer = new VideoCastConsumerImpl() { @Override public void onApplicationConnected( ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { // In case we are casting, send the device name as an extra on MediaSession metadata. mSessionExtras.putString(EXTRA_CONNECTED_CAST, mCastManager.getDeviceName()); mSession.setExtras(mSessionExtras); // Now we can switch to CastPlayback Playback playback = new CastPlayback(MusicService.this, mMusicProvider); mMediaRouter.setMediaSession(mSession); switchToPlayer(playback, true); } @Override public void onDisconnected() { LogHelper.d(TAG, "onDisconnected"); mSessionExtras.remove(EXTRA_CONNECTED_CAST); mSession.setExtras(mSessionExtras); Playback playback = new LocalPlayback(MusicService.this, mMusicProvider); mMediaRouter.setMediaSession(null); switchToPlayer(playback, false); } }; private VideoCastManager mCastManager; /* * (non-Javadoc) * @see android.app.Service#onCreate() */ @Override public void onCreate() { super.onCreate(); LogHelper.d(TAG, "onCreate"); mPlayingQueue = new ArrayList<>(); mMusicProvider = new MusicProvider(); mPackageValidator = new PackageValidator(this); // Start a new MediaSession mSession = new MediaSession(this, "MusicService"); setSessionToken(mSession.getSessionToken()); mSession.setCallback(new MediaSessionCallback()); mSession.setFlags( MediaSession.FLAG_HANDLES_MEDIA_BUTTONS | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); mPlayback = new LocalPlayback(this, mMusicProvider); mPlayback.setState(PlaybackState.STATE_NONE); mPlayback.setCallback(this); mPlayback.start(); Context context = getApplicationContext(); Intent intent = new Intent(context, NowPlayingActivity.class); PendingIntent pi = PendingIntent.getActivity( context, 99 /*request code*/, intent, PendingIntent.FLAG_UPDATE_CURRENT); mSession.setSessionActivity(pi); mSessionExtras = new Bundle(); CarHelper.setSlotReservationFlags(mSessionExtras, true, true, true); mSession.setExtras(mSessionExtras); updatePlaybackState(null); mMediaNotificationManager = new MediaNotificationManager(this); mCastManager = ((UAMPApplication) getApplication()).getCastManager(getApplicationContext()); mCastManager.addVideoCastConsumer(mCastConsumer); mMediaRouter = MediaRouter.getInstance(getApplicationContext()); } /** * (non-Javadoc) * * @see android.app.Service#onStartCommand(android.content.Intent, int, int) */ @Override public int onStartCommand(Intent startIntent, int flags, int startId) { if (startIntent != null) { String action = startIntent.getAction(); String command = startIntent.getStringExtra(CMD_NAME); if (ACTION_CMD.equals(action)) { if (CMD_PAUSE.equals(command)) { if (mPlayback != null && mPlayback.isPlaying()) { handlePauseRequest(); } } else if (CMD_STOP_CASTING.equals(command)) { mCastManager.disconnect(); } } } return START_STICKY; } /** * (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); mCastManager = ((UAMPApplication) getApplication()).getCastManager(getApplicationContext()); mCastManager.removeVideoCastConsumer(mCastConsumer); mDelayedStopHandler.removeCallbacksAndMessages(null); // Always release the MediaSession to clean up resources // and notify associated MediaController(s). mSession.release(); } @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 (!mPackageValidator.isCallerAllowed(this, clientPackageName, clientUid)) { // 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; } //noinspection StatementWithEmptyBody if (CarHelper.isValidCarPackage(clientPackageName)) { // Optional: if your app needs to adapt ads, music library or anything else that // needs to run differently when connected to the car, this is where you should handle // it. } 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.retrieveMediaAsync( new MusicProvider.Callback() { @Override public void onMusicCatalogReady(boolean success) { if (success) { loadChildrenImpl(parentMediaId, result); } else { updatePlaybackState(getString(R.string.error_no_metadata)); result.sendResult(Collections.<MediaItem>emptyList()); } } }); } 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.uamp/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 = MediaIDHelper.getHierarchy(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.createMediaID( track.getDescription().getMediaId(), MEDIA_ID_MUSICS_BY_GENRE, genre); 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); } LogHelper.d(TAG, "OnLoadChildren sending ", mediaItems.size(), " results for ", parentMediaId); result.sendResult(mediaItems); } 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 onSeekTo(long position) { LogHelper.d(TAG, "onSeekTo:", position); mPlayback.seekTo((int) position); } @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()) { // set the current index on queue from the media Id: mCurrentIndexOnQueue = QueueHelper.getMusicIndexOnQueue(mPlayingQueue, mediaId); if (mCurrentIndexOnQueue < 0) { LogHelper.e( TAG, "playFromMediaId: media ID ", mediaId, " could not be found on queue. Ignoring."); } else { // play the music handlePlayRequest(); } } } @Override public void onPause() { LogHelper.d(TAG, "pause. current state=" + mPlayback.getState()); handlePauseRequest(); } @Override public void onStop() { LogHelper.d(TAG, "stop. current state=" + mPlayback.getState()); handleStopRequest(null); } @Override public void onSkipToNext() { LogHelper.d(TAG, "skipToNext"); mCurrentIndexOnQueue++; if (mPlayingQueue != null && mCurrentIndexOnQueue >= mPlayingQueue.size()) { // This sample's behavior: skipping to next when in last song returns to the // first song. mCurrentIndexOnQueue = 0; } if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { 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)) { 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 musicId = track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); mMusicProvider.setFavorite(musicId, !mMusicProvider.isFavorite(musicId)); } // playback state needs to be updated because the "Favorite" icon on the // custom action will change to reflect the new favorite state. 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(); } } } /** Handle a request to play music */ private void handlePlayRequest() { LogHelper.d(TAG, "handlePlayRequest: mState=" + mPlayback.getState()); mDelayedStopHandler.removeCallbacksAndMessages(null); if (!mServiceStarted) { LogHelper.v(TAG, "Starting service"); // The MusicService needs to keep running even after the calling MediaBrowser // is disconnected. Call startService(Intent) and then stopSelf(..) when we no longer // need to play media. startService(new Intent(getApplicationContext(), MusicService.class)); mServiceStarted = true; } if (!mSession.isActive()) { mSession.setActive(true); } if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { updateMetadata(); mPlayback.play(mPlayingQueue.get(mCurrentIndexOnQueue)); } } /** Handle a request to pause music */ private void handlePauseRequest() { LogHelper.d(TAG, "handlePauseRequest: mState=" + mPlayback.getState()); mPlayback.pause(); // reset the delayed stop handler. mDelayedStopHandler.removeCallbacksAndMessages(null); mDelayedStopHandler.sendEmptyMessageDelayed(0, STOP_DELAY); } /** Handle a request to stop music */ private void handleStopRequest(String withError) { LogHelper.d(TAG, "handleStopRequest: mState=" + mPlayback.getState() + " error=", withError); mPlayback.stop(true); // reset the delayed stop handler. mDelayedStopHandler.removeCallbacksAndMessages(null); mDelayedStopHandler.sendEmptyMessageDelayed(0, STOP_DELAY); updatePlaybackState(withError); // service is no longer necessary. Will be started again if needed. stopSelf(); mServiceStarted = false; } private void updateMetadata() { if (!QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { LogHelper.e(TAG, "Can't retrieve current metadata."); updatePlaybackState(getResources().getString(R.string.error_no_metadata)); return; } MediaSession.QueueItem queueItem = mPlayingQueue.get(mCurrentIndexOnQueue); String musicId = MediaIDHelper.extractMusicIDFromMediaID(queueItem.getDescription().getMediaId()); MediaMetadata track = mMusicProvider.getMusic(musicId); final String trackId = track.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); if (!musicId.equals(trackId)) { IllegalStateException e = new IllegalStateException("track ID should match musicId."); LogHelper.e( TAG, "track ID should match musicId.", " musicId=", musicId, " trackId=", trackId, " mediaId from queueItem=", queueItem.getDescription().getMediaId(), " title from queueItem=", queueItem.getDescription().getTitle(), " mediaId from track=", track.getDescription().getMediaId(), " title from track=", track.getDescription().getTitle(), " source.hashcode from track=", track.getString(MusicProvider.CUSTOM_METADATA_TRACK_SOURCE).hashCode(), e); throw e; } LogHelper.d(TAG, "Updating metadata for MusicID= " + musicId); mSession.setMetadata(track); // Set the proper album artwork on the media session, so it can be shown in the // locked screen and in other places. if (track.getDescription().getIconBitmap() == null && track.getDescription().getIconUri() != null) { String albumUri = track.getDescription().getIconUri().toString(); AlbumArtCache.getInstance() .fetch( albumUri, new AlbumArtCache.FetchListener() { @Override public void onFetched(String artUrl, Bitmap bitmap, Bitmap icon) { MediaSession.QueueItem queueItem = mPlayingQueue.get(mCurrentIndexOnQueue); MediaMetadata track = mMusicProvider.getMusic(trackId); track = new MediaMetadata.Builder(track) // set high resolution bitmap in METADATA_KEY_ALBUM_ART. This is used, for // example, on the lockscreen background when the media session is active. .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) // set small version of the album art in the DISPLAY_ICON. This is used on // the MediaDescription and thus it should be small to be serialized if // necessary.. .putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, icon) .build(); MediaDescription md; mMusicProvider.updateMusic(trackId, track); // If we are still playing the same music String currentPlayingId = MediaIDHelper.extractMusicIDFromMediaID( queueItem.getDescription().getMediaId()); if (trackId.equals(currentPlayingId)) { 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, playback state=" + mPlayback.getState()); long position = PlaybackState.PLAYBACK_POSITION_UNKNOWN; if (mPlayback != null && mPlayback.isConnected()) { position = mPlayback.getCurrentStreamPosition(); } PlaybackState.Builder stateBuilder = new PlaybackState.Builder().setActions(getAvailableActions()); setCustomAction(stateBuilder); int state = mPlayback.getState(); // 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); state = PlaybackState.STATE_ERROR; } stateBuilder.setState(state, position, 1.0f, SystemClock.elapsedRealtime()); // Set the activeQueueItemId if the current index is valid. if (QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { MediaSession.QueueItem item = mPlayingQueue.get(mCurrentIndexOnQueue); stateBuilder.setActiveQueueItemId(item.getQueueId()); } mSession.setPlaybackState(stateBuilder.build()); if (state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_PAUSED) { mMediaNotificationManager.startNotification(); } } private void setCustomAction(PlaybackState.Builder stateBuilder) { MediaMetadata currentMusic = getCurrentPlayingMusic(); if (currentMusic != null) { // Set appropriate "Favorite" icon on Custom action: String musicId = currentMusic.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); int favoriteIcon = R.drawable.ic_star_off; if (mMusicProvider.isFavorite(musicId)) { favoriteIcon = R.drawable.ic_star_on; } LogHelper.d( TAG, "updatePlaybackState, setting Favorite custom action of music ", musicId, " current favorite=", mMusicProvider.isFavorite(musicId)); 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 (mPlayback.isPlaying()) { 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( MediaIDHelper.extractMusicIDFromMediaID(item.getDescription().getMediaId())); } } return null; } /** Implementation of the Playback.Callback interface */ @Override public void onCompletion() { // 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); } } @Override public void onPlaybackStatusChanged(int state) { updatePlaybackState(null); } @Override public void onError(String error) { updatePlaybackState(error); } @Override public void onMetadataChanged(String mediaId) { LogHelper.d(TAG, "onMetadataChanged", mediaId); List<MediaSession.QueueItem> queue = QueueHelper.getPlayingQueue(mediaId, mMusicProvider); int index = QueueHelper.getMusicIndexOnQueue(queue, mediaId); if (index > -1) { mCurrentIndexOnQueue = index; mPlayingQueue = queue; updateMetadata(); } } /** * Helper to switch to a different Playback instance * * @param playback switch to this playback */ private void switchToPlayer(Playback playback, boolean resumePlaying) { if (playback == null) { throw new IllegalArgumentException("Playback cannot be null"); } // suspend the current one. int oldState = mPlayback.getState(); int pos = mPlayback.getCurrentStreamPosition(); String currentMediaId = mPlayback.getCurrentMediaId(); LogHelper.d(TAG, "Current position from " + playback + " is ", pos); mPlayback.stop(false); playback.setCallback(this); playback.setCurrentStreamPosition(pos < 0 ? 0 : pos); playback.setCurrentMediaId(currentMediaId); playback.start(); // finally swap the instance mPlayback = playback; switch (oldState) { case PlaybackState.STATE_BUFFERING: case PlaybackState.STATE_CONNECTING: case PlaybackState.STATE_PAUSED: mPlayback.pause(); break; case PlaybackState.STATE_PLAYING: if (resumePlaying && QueueHelper.isIndexPlayable(mCurrentIndexOnQueue, mPlayingQueue)) { mPlayback.play(mPlayingQueue.get(mCurrentIndexOnQueue)); } else if (!resumePlaying) { mPlayback.pause(); } else { mPlayback.stop(true); } break; case PlaybackState.STATE_NONE: break; default: LogHelper.d(TAG, "Default called. Old state is ", oldState); } } /** A simple handler that stops the service if playback is not active (playing) */ private static class DelayedStopHandler extends Handler { private final WeakReference<MusicService> mWeakReference; private DelayedStopHandler(MusicService service) { mWeakReference = new WeakReference<>(service); } @Override public void handleMessage(Message msg) { MusicService service = mWeakReference.get(); if (service != null && service.mPlayback != null) { if (service.mPlayback.isPlaying()) { LogHelper.d(TAG, "Ignoring delayed stop since the media player is in use."); return; } LogHelper.d(TAG, "Stopping service with delay handler."); service.stopSelf(); service.mServiceStarted = false; } } } }
/** Utility class to get a list of MusicTrack's based on a server-side JSON configuration. */ public class MusicProvider { private static final String TAG = LogHelper.makeLogTag(MusicProvider.class); 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 final String JSON_MUSIC = "music"; private static final String JSON_TITLE = "title"; private static final String JSON_ALBUM = "album"; private static final String JSON_ARTIST = "artist"; private static final String JSON_GENRE = "genre"; private static final String JSON_SOURCE = "source"; private static final String JSON_IMAGE = "image"; private static final String JSON_TRACK_NUMBER = "trackNumber"; private static final String JSON_TOTAL_TRACK_COUNT = "totalTrackCount"; private static final String JSON_DURATION = "duration"; // Categorized caches for music track data: private ConcurrentMap<String, List<MediaMetadata>> mMusicListByGenre; private final ConcurrentMap<String, MutableMediaMetadata> mMusicListById; private final Set<String> mFavoriteTracks; enum State { NON_INITIALIZED, INITIALIZING, INITIALIZED } private volatile State mCurrentState = State.NON_INITIALIZED; public interface Callback { void onMusicCatalogReady(boolean success); } public MusicProvider() { mMusicListByGenre = new ConcurrentHashMap<>(); mMusicListById = new ConcurrentHashMap<>(); mFavoriteTracks = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>()); } /** * Get an iterator over the list of genres * * @return genres */ public Iterable<String> getGenres() { if (mCurrentState != State.INITIALIZED) { return Collections.emptyList(); } return mMusicListByGenre.keySet(); } /** Get music tracks of the given genre */ public Iterable<MediaMetadata> getMusicsByGenre(String genre) { if (mCurrentState != State.INITIALIZED || !mMusicListByGenre.containsKey(genre)) { return Collections.emptyList(); } return mMusicListByGenre.get(genre); } /** * Very basic implementation of a search that filter music tracks which title containing the given * query. */ public Iterable<MediaMetadata> searchMusic(String titleQuery) { if (mCurrentState != State.INITIALIZED) { return Collections.emptyList(); } ArrayList<MediaMetadata> result = new ArrayList<>(); titleQuery = titleQuery.toLowerCase(); for (MutableMediaMetadata track : mMusicListById.values()) { if (track .metadata .getString(MediaMetadata.METADATA_KEY_TITLE) .toLowerCase() .contains(titleQuery)) { result.add(track.metadata); } } return result; } /** * Return the MediaMetadata for the given musicID. * * @param musicId The unique, non-hierarchical music ID. */ public MediaMetadata getMusic(String musicId) { return mMusicListById.containsKey(musicId) ? mMusicListById.get(musicId).metadata : null; } public synchronized void updateMusic(String musicId, MediaMetadata metadata) { MutableMediaMetadata track = mMusicListById.get(musicId); if (track == null) { return; } String oldGenre = track.metadata.getString(MediaMetadata.METADATA_KEY_GENRE); String newGenre = metadata.getString(MediaMetadata.METADATA_KEY_GENRE); track.metadata = metadata; // if genre has changed, we need to rebuild the list by genre if (!oldGenre.equals(newGenre)) { buildListsByGenre(); } } public void setFavorite(String musicId, boolean favorite) { if (favorite) { mFavoriteTracks.add(musicId); } else { mFavoriteTracks.remove(musicId); } } 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 musicId and grouping by genre. */ public void retrieveMediaAsync(final Callback callback) { LogHelper.d(TAG, "retrieveMediaAsync called"); 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<Void, Void, State>() { @Override protected State doInBackground(Void... params) { retrieveMedia(); return mCurrentState; } @Override protected void onPostExecute(State current) { if (callback != null) { callback.onMusicCatalogReady(current == State.INITIALIZED); } } }.execute(); } private synchronized void buildListsByGenre() { ConcurrentMap<String, List<MediaMetadata>> newMusicListByGenre = new ConcurrentHashMap<>(); for (MutableMediaMetadata m : mMusicListById.values()) { String genre = m.metadata.getString(MediaMetadata.METADATA_KEY_GENRE); List<MediaMetadata> list = newMusicListByGenre.get(genre); if (list == null) { list = new ArrayList<>(); newMusicListByGenre.put(genre, list); } list.add(m.metadata); } mMusicListByGenre = newMusicListByGenre; } private synchronized void retrieveMedia() { try { if (mCurrentState == State.NON_INITIALIZED) { mCurrentState = State.INITIALIZING; int slashPos = CATALOG_URL.lastIndexOf('/'); String path = CATALOG_URL.substring(0, slashPos + 1); JSONObject jsonObj = fetchJSONFromUrl(CATALOG_URL); if (jsonObj == null) { return; } 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 musicId = item.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); mMusicListById.put(musicId, new MutableMediaMetadata(musicId, item)); } buildListsByGenre(); } mCurrentState = State.INITIALIZED; } } catch (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; } } } 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. * * @return result JSONObject containing the parsed representation. */ private JSONObject fetchJSONFromUrl(String urlString) { InputStream is = null; try { URL url = new URL(urlString); URLConnection urlConnection = url.openConnection(); is = new BufferedInputStream(urlConnection.getInputStream()); BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), "iso-8859-1")); StringBuilder sb = new StringBuilder(); String line; 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 } } } } }
/** A class that shows the Media Queue to the user. */ public class PlaybackControlsFragment extends Fragment { private static final String TAG = LogHelper.makeLogTag(PlaybackControlsFragment.class); private ImageButton mPlayPause; private TextView mTitle; private TextView mSubtitle; private TextView mExtraInfo; private ImageView mAlbumArt; private String mArtUrl; // Receive callbacks from the MediaController. Here we update our state such as which queue // is being shown, the current title and description and the PlaybackState. private final MediaControllerCompat.Callback mCallback = new MediaControllerCompat.Callback() { @Override public void onPlaybackStateChanged(@NonNull PlaybackStateCompat state) { LogHelper.d(TAG, "Received playback state change to state ", state.getState()); PlaybackControlsFragment.this.onPlaybackStateChanged(state); } @Override public void onMetadataChanged(MediaMetadataCompat metadata) { if (metadata == null) { return; } LogHelper.d( TAG, "Received metadata state change to mediaId=", metadata.getDescription().getMediaId(), " song=", metadata.getDescription().getTitle()); PlaybackControlsFragment.this.onMetadataChanged(metadata); } }; @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_playback_controls, container, false); mPlayPause = (ImageButton) rootView.findViewById(R.id.play_pause); mPlayPause.setEnabled(true); mPlayPause.setOnClickListener(mButtonListener); mTitle = (TextView) rootView.findViewById(R.id.title); mSubtitle = (TextView) rootView.findViewById(R.id.artist); mExtraInfo = (TextView) rootView.findViewById(R.id.extra_info); mAlbumArt = (ImageView) rootView.findViewById(R.id.album_art); rootView.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { Intent intent = new Intent(getActivity(), FullScreenPlayerActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); MediaMetadataCompat metadata = controller.getMetadata(); if (metadata != null) { intent.putExtra( MainActivity.EXTRA_CURRENT_MEDIA_DESCRIPTION, metadata.getDescription()); } startActivity(intent); } }); return rootView; } @Override public void onStart() { super.onStart(); LogHelper.d(TAG, "fragment.onStart"); MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); if (controller != null) { onConnected(); } } @Override public void onStop() { super.onStop(); LogHelper.d(TAG, "fragment.onStop"); MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); if (controller != null) { controller.unregisterCallback(mCallback); } } public void onConnected() { MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); LogHelper.d(TAG, "onConnected, mediaController==null? ", controller == null); if (controller != null) { onMetadataChanged(controller.getMetadata()); onPlaybackStateChanged(controller.getPlaybackState()); controller.registerCallback(mCallback); } } private void onMetadataChanged(MediaMetadataCompat metadata) { LogHelper.d(TAG, "onMetadataChanged ", metadata); if (getActivity() == null) { LogHelper.w( TAG, "onMetadataChanged called when getActivity null," + "this should not happen if the callback was properly unregistered. Ignoring."); return; } if (metadata == null) { return; } mTitle.setText(metadata.getDescription().getTitle()); mSubtitle.setText(metadata.getDescription().getSubtitle()); String artUrl = null; if (metadata.getDescription().getIconUri() != null) { artUrl = metadata.getDescription().getIconUri().toString(); } if (!TextUtils.equals(artUrl, mArtUrl)) { mArtUrl = artUrl; Bitmap art = metadata.getDescription().getIconBitmap(); AlbumArtCache cache = AlbumArtCache.getInstance(); if (art == null) { art = cache.getIconImage(mArtUrl); } if (art != null) { mAlbumArt.setImageBitmap(art); } else { cache.fetch( artUrl, new AlbumArtCache.FetchListener() { @Override public void onFetched(String artUrl, Bitmap bitmap, Bitmap icon) { if (icon != null) { LogHelper.d( TAG, "album art icon of w=", icon.getWidth(), " h=", icon.getHeight()); if (isAdded()) { mAlbumArt.setImageBitmap(icon); } } } }); } } } public void setExtraInfo(String extraInfo) { if (extraInfo == null) { mExtraInfo.setVisibility(View.GONE); } else { mExtraInfo.setText(extraInfo); mExtraInfo.setVisibility(View.VISIBLE); } } private void onPlaybackStateChanged(PlaybackStateCompat state) { LogHelper.d(TAG, "onPlaybackStateChanged ", state); if (getActivity() == null) { LogHelper.w( TAG, "onPlaybackStateChanged called when getActivity null," + "this should not happen if the callback was properly unregistered. Ignoring."); return; } if (state == null) { return; } boolean enablePlay = false; switch (state.getState()) { case PlaybackStateCompat.STATE_PAUSED: case PlaybackStateCompat.STATE_STOPPED: enablePlay = true; break; case PlaybackStateCompat.STATE_ERROR: LogHelper.e(TAG, "error playbackstate: ", state.getErrorMessage()); Toast.makeText(getActivity(), state.getErrorMessage(), Toast.LENGTH_LONG).show(); break; } if (enablePlay) { mPlayPause.setImageDrawable( ContextCompat.getDrawable(getActivity(), R.drawable.ic_play_arrow_black_36dp)); } else { mPlayPause.setImageDrawable( ContextCompat.getDrawable(getActivity(), R.drawable.ic_pause_black_36dp)); } MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); String extraInfo = null; if (controller != null && controller.getExtras() != null) { String castName = controller.getExtras().getString(MusicService.EXTRA_CONNECTED_CAST); if (castName != null) { extraInfo = getResources().getString(R.string.casting_to_device, castName); } } setExtraInfo(extraInfo); } private final View.OnClickListener mButtonListener = new View.OnClickListener() { @Override public void onClick(View v) { MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); PlaybackStateCompat stateObj = controller.getPlaybackState(); final int state = stateObj == null ? PlaybackStateCompat.STATE_NONE : stateObj.getState(); LogHelper.d(TAG, "Button pressed, in state " + state); switch (v.getId()) { case R.id.play_pause: LogHelper.d(TAG, "Play button pressed, in state " + state); if (state == PlaybackStateCompat.STATE_PAUSED || state == PlaybackStateCompat.STATE_STOPPED || state == PlaybackStateCompat.STATE_NONE) { playMedia(); } else if (state == PlaybackStateCompat.STATE_PLAYING || state == PlaybackStateCompat.STATE_BUFFERING || state == PlaybackStateCompat.STATE_CONNECTING) { pauseMedia(); } break; } } }; private void playMedia() { MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); if (controller != null) { controller.getTransportControls().play(); } } private void pauseMedia() { MediaControllerCompat controller = ((FragmentActivity) getActivity()).getSupportMediaController(); if (controller != null) { controller.getTransportControls().pause(); } } }