1 /*
2  * Copyright 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.media.testmediaapp;
18 
19 import static android.media.AudioManager.AUDIOFOCUS_GAIN;
20 import static android.media.AudioManager.AUDIOFOCUS_LOSS;
21 import static android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT;
22 import static android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK;
23 import static android.media.AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
24 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE;
25 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
26 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID;
27 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE;
28 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SEEK_TO;
29 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
30 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
31 import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM;
32 import static android.support.v4.media.session.PlaybackStateCompat.ERROR_CODE_APP_ERROR;
33 import static android.support.v4.media.session.PlaybackStateCompat.STATE_ERROR;
34 
35 import android.media.AudioManager.OnAudioFocusChangeListener;
36 import androidx.annotation.Nullable;
37 
38 import android.app.PendingIntent;
39 import android.content.Intent;
40 import android.media.AudioFocusRequest;
41 import android.media.AudioManager;
42 import android.os.Bundle;
43 import android.os.Handler;
44 import android.support.v4.media.session.MediaSessionCompat;
45 import android.support.v4.media.session.PlaybackStateCompat;
46 import android.text.TextUtils;
47 import android.util.Log;
48 import android.widget.Toast;
49 
50 import com.android.car.media.testmediaapp.TmaMediaEvent.Action;
51 import com.android.car.media.testmediaapp.TmaMediaEvent.EventState;
52 import com.android.car.media.testmediaapp.TmaMediaEvent.ResolutionIntent;
53 import com.android.car.media.testmediaapp.TmaMediaItem.TmaCustomAction;
54 import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaAccountType;
55 import com.android.car.media.testmediaapp.prefs.TmaPrefs;
56 import com.android.car.media.testmediaapp.prefs.TmaPrefsActivity;
57 
58 
59 /**
60  * This class simulates all media interactions (no sound is actually played).
61  */
62 public class TmaPlayer extends MediaSessionCompat.Callback {
63 
64     private static final String TAG = "TmaPlayer";
65 
66     private final TmaBrowser mBrowser;
67     private final TmaPrefs mPrefs;
68     private final TmaLibrary mLibrary;
69     private final AudioManager mAudioManager;
70     private final Handler mHandler;
71     private final Runnable mTrackTimer = this::onStop;
72     private final Runnable mEventTrigger = this::onProcessMediaEvent;
73     private final MediaSessionCompat mSession;
74     private final AudioFocusRequest mAudioFocusRequest;
75 
76     /** Only updated when the state changes. */
77     private long mCurrentPositionMs = 0;
78     private float mPlaybackSpeed = 1.0f; // TODO: make variable.
79     private long mPlaybackStartTimeMs;
80     private boolean mIsPlaying;
81     @Nullable
82     private TmaMediaItem mActiveItem;
83     private int mNextEventIndex = -1;
84     private boolean mResumeOnFocusGain;
85 
TmaPlayer(TmaBrowser browser, TmaLibrary library, AudioManager audioManager, Handler handler, MediaSessionCompat session)86     TmaPlayer(TmaBrowser browser, TmaLibrary library, AudioManager audioManager, Handler handler,
87             MediaSessionCompat session) {
88         mBrowser = browser;
89         mPrefs = TmaPrefs.getInstance(mBrowser);
90         mLibrary = library;
91         mAudioManager = audioManager;
92         mHandler = handler;
93         mSession = session;
94 
95         mAudioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN)
96             .setOnAudioFocusChangeListener(this::onAudioFocusChange, mHandler)
97             .build();
98     }
99 
100     /** Updates the state in the media session based on the given {@link TmaMediaEvent}. */
setPlaybackState(TmaMediaEvent event)101     void setPlaybackState(TmaMediaEvent event) {
102         Log.i(TAG, "setPlaybackState " + event);
103 
104         PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
105                 .setState(event.mState.mValue, mCurrentPositionMs, mPlaybackSpeed)
106                 .setErrorMessage(event.mErrorCode.mValue, event.mErrorMessage)
107                 .setActions(addActions(ACTION_PAUSE));
108         if (ResolutionIntent.PREFS.equals(event.mResolutionIntent)) {
109             Intent prefsIntent = new Intent();
110             prefsIntent.setClass(mBrowser, TmaPrefsActivity.class);
111             prefsIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
112             PendingIntent pendingIntent = PendingIntent.getActivity(mBrowser, 0, prefsIntent,
113                     PendingIntent.FLAG_IMMUTABLE);
114 
115             Bundle extras = new Bundle();
116             extras.putString(MediaKeys.ERROR_RESOLUTION_ACTION_LABEL, event.mActionLabel);
117             extras.putParcelable(MediaKeys.ERROR_RESOLUTION_ACTION_INTENT, pendingIntent);
118             state.setExtras(extras);
119         }
120 
121         setActiveItemState(state);
122         mSession.setPlaybackState(state.build());
123     }
124 
125     /** Sets custom action, queue id, etc. */
setActiveItemState(PlaybackStateCompat.Builder state)126     private void setActiveItemState(PlaybackStateCompat.Builder state) {
127         if (mActiveItem != null) {
128             for (TmaCustomAction action : mActiveItem.mCustomActions) {
129                 String name = mBrowser.getResources().getString(action.mNameId);
130                 state.addCustomAction(action.mId, name, action.mIcon);
131             }
132             state.setActiveQueueItemId(mActiveItem.getQueueId());
133         }
134     }
135 
playItem(@ullable TmaMediaItem item)136     private void playItem(@Nullable TmaMediaItem item) {
137         if (item != null && item.getParent() != null) {
138             if (mIsPlaying) {
139                 stopPlayback();
140             }
141             mActiveItem = item;
142             mSession.setQueue(item.getParent().buildQueue());
143             startPlayBack(true);
144         }
145     }
146 
147     @Override
onPlayFromMediaId(String mediaId, Bundle extras)148     public void onPlayFromMediaId(String mediaId, Bundle extras) {
149         super.onPlayFromMediaId(mediaId, extras);
150         playItem(mLibrary.getMediaItemById(mediaId));
151     }
152 
153     @Override
onPrepareFromMediaId(String mediaId, Bundle extras)154     public void onPrepareFromMediaId(String mediaId, Bundle extras) {
155         super.onPrepareFromMediaId(mediaId, extras);
156 
157         TmaMediaItem item = mLibrary.getMediaItemById(mediaId);
158         prepareMediaItem(item);
159     }
160 
161     @Override
onPrepare()162     public void onPrepare() {
163         super.onPrepare();
164         if (!mSession.isActive()) {
165             mSession.setActive(true);
166         }
167         // Prepare the first playable item (at root level) as the active item
168         if (mActiveItem == null) {
169             TmaMediaItem root = mLibrary.getRoot(mPrefs.mRootNodeType.getValue());
170             if (root != null) {
171                 prepareMediaItem(root.getPlayableByIndex(0));
172             }
173         }
174     }
175 
prepareMediaItem(@ullable TmaMediaItem item)176     void prepareMediaItem(@Nullable TmaMediaItem item) {
177         if (item != null && item.getParent() != null) {
178             if (mIsPlaying) {
179                 stopPlayback();
180             }
181             mActiveItem = item;
182             mActiveItem.updateSessionMetadata(mSession);
183             mSession.setQueue(item.getParent().buildQueue());
184 
185             PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
186                     .setState(PlaybackStateCompat.STATE_PAUSED, mCurrentPositionMs, mPlaybackSpeed)
187                     .setActions(addActions(ACTION_PLAY));
188             setActiveItemState(state);
189             mSession.setPlaybackState(state.build());
190         }
191     }
192 
193     @Override
onSkipToQueueItem(long id)194     public void onSkipToQueueItem(long id) {
195         super.onSkipToQueueItem(id);
196         if (mActiveItem != null && mActiveItem.getParent() != null) {
197             playItem(mActiveItem.getParent().getPlayableByIndex(id));
198         }
199     }
200 
201     @Override
onSkipToNext()202     public void onSkipToNext() {
203         super.onSkipToNext();
204         if (mActiveItem != null) {
205             playItem(mActiveItem.getNext());
206         }
207     }
208 
209     @Override
onSkipToPrevious()210     public void onSkipToPrevious() {
211         super.onSkipToPrevious();
212         if (mActiveItem != null) {
213             playItem(mActiveItem.getPrevious());
214         }
215     }
216 
217     @Override
onPlay()218     public void onPlay() {
219         super.onPlay();
220         startPlayBack(true);
221     }
222 
223     @Override
onSeekTo(long pos)224     public void onSeekTo(long pos) {
225         super.onSeekTo(pos);
226         boolean wasPlaying = mIsPlaying;
227         if (wasPlaying) {
228             mHandler.removeCallbacks(mTrackTimer);
229         }
230         mCurrentPositionMs = pos;
231         boolean requestAudioFocus = !wasPlaying;
232         startPlayBack(requestAudioFocus);
233     }
234 
235     @Override
onPause()236     public void onPause() {
237         super.onPause();
238         pausePlayback();
239     }
240 
241     @Override
onStop()242     public void onStop() {
243         super.onStop();
244         stopPlayback();
245         sendStopPlaybackState();
246     }
247 
248     @Override
onCustomAction(String action, Bundle extras)249     public void onCustomAction(String action, Bundle extras) {
250         super.onCustomAction(action, extras);
251         if (mActiveItem != null) {
252             if (TmaCustomAction.HEART_PLUS_PLUS.mId.equals(action)) {
253                 mActiveItem.mHearts++;
254                 toast("" + mActiveItem.mHearts);
255             } else if (TmaCustomAction.HEART_LESS_LESS.mId.equals(action)) {
256                 mActiveItem.mHearts--;
257                 toast("" + mActiveItem.mHearts);
258             } else if (TmaCustomAction.REQUEST_LOCATION.mId.equals(action)) {
259                 mBrowser.startService(new Intent(mBrowser, TmaForegroundService.class));
260             }
261         }
262     }
263 
264     /** Note: this is for quick feedback implementation, media apps should avoid toasts... */
toast(String message)265     private void toast(String message) {
266         Toast.makeText(mBrowser, message, Toast.LENGTH_LONG).show();
267     }
268 
audioFocusGranted()269     private boolean audioFocusGranted() {
270         return mAudioManager.requestAudioFocus(mAudioFocusRequest) == AUDIOFOCUS_REQUEST_GRANTED;
271     }
272 
onProcessMediaEvent()273     private void onProcessMediaEvent() {
274         if (mActiveItem == null) return;
275 
276         TmaMediaEvent event = mActiveItem.mMediaEvents.get(mNextEventIndex);
277         event.maybeThrow();
278         if (!TextUtils.isEmpty(event.mMediaItemIdToToggle)) {
279             mBrowser.toggleItem(mLibrary.getMediaItemById(event.mMediaItemIdToToggle));
280         }
281 
282         if (event.premiumAccountRequired() &&
283                 TmaAccountType.PAID.equals(mPrefs.mAccountType.getValue())) {
284             Log.i(TAG, "Ignoring even for paid account");
285             return;
286         } else if (Action.RESET_METADATA.equals(event.mAction)) {
287             mSession.setMetadata(mSession.getController().getMetadata());
288         } else {
289             setPlaybackState(event);
290         }
291 
292         if (event.mState == EventState.PLAYING) {
293             if (!mSession.isActive()) {
294                 mSession.setActive(true);
295             }
296 
297             long trackDurationMs = mActiveItem.getDuration();
298             if (trackDurationMs > 0) {
299                 mPlaybackStartTimeMs = System.currentTimeMillis();
300                 long remainingMs = (long) ((trackDurationMs - mCurrentPositionMs) / mPlaybackSpeed);
301                 mHandler.postDelayed(mTrackTimer, remainingMs);
302             }
303             mIsPlaying = true;
304         } else if (mIsPlaying) {
305             stopPlayback();
306         }
307 
308         mNextEventIndex++;
309         if (mNextEventIndex < mActiveItem.mMediaEvents.size()) {
310             mHandler.postDelayed(mEventTrigger,
311                     mActiveItem.mMediaEvents.get(mNextEventIndex).mPostDelayMs);
312         }
313     }
314 
startPlayBack(boolean requestAudioFocus)315     private void startPlayBack(boolean requestAudioFocus) {
316         if (requestAudioFocus && !audioFocusGranted()) return;
317 
318         if (mActiveItem == null || mActiveItem.mMediaEvents.size() <= 0) {
319             PlaybackStateCompat state = new PlaybackStateCompat.Builder()
320                     .setState(STATE_ERROR, mCurrentPositionMs, mPlaybackSpeed)
321                     .setErrorMessage(ERROR_CODE_APP_ERROR, "null mActiveItem or empty events")
322                     .build();
323             mSession.setPlaybackState(state);
324             return;
325         }
326 
327         mActiveItem.updateSessionMetadata(mSession);
328 
329         mHandler.removeCallbacks(mEventTrigger);
330         mNextEventIndex = 0;
331         mHandler.postDelayed(mEventTrigger, mActiveItem.mMediaEvents.get(0).mPostDelayMs);
332     }
333 
pausePlayback()334     private void pausePlayback() {
335         mCurrentPositionMs += (System.currentTimeMillis() - mPlaybackStartTimeMs) / mPlaybackSpeed;
336         mHandler.removeCallbacks(mTrackTimer);
337         PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
338                 .setState(PlaybackStateCompat.STATE_PAUSED, mCurrentPositionMs, mPlaybackSpeed)
339                 .setActions(addActions(ACTION_PLAY));
340         setActiveItemState(state);
341         mSession.setPlaybackState(state.build());
342         mIsPlaying = false;
343     }
344 
345     /** Doesn't change the playback state. */
stopPlayback()346     private void stopPlayback() {
347         mCurrentPositionMs = 0;
348         mHandler.removeCallbacks(mTrackTimer);
349         mIsPlaying = false;
350     }
351 
sendStopPlaybackState()352     private void sendStopPlaybackState() {
353         PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
354                 .setState(PlaybackStateCompat.STATE_STOPPED, mCurrentPositionMs, mPlaybackSpeed)
355                 .setActions(addActions(ACTION_PLAY));
356         setActiveItemState(state);
357         mSession.setPlaybackState(state.build());
358     }
359 
addActions(long actions)360     private long addActions(long actions) {
361         actions |= ACTION_PLAY_FROM_MEDIA_ID | ACTION_SKIP_TO_QUEUE_ITEM | ACTION_SEEK_TO
362                 | ACTION_PREPARE;
363 
364         if (mActiveItem != null) {
365             if (mActiveItem.getNext() != null) {
366                 actions |= ACTION_SKIP_TO_NEXT;
367             }
368             if (mActiveItem.getPrevious() != null) {
369                 actions |= ACTION_SKIP_TO_PREVIOUS;
370             }
371         }
372 
373         return actions;
374     }
375 
onAudioFocusChange(int focusChange)376     private void onAudioFocusChange(int focusChange) {
377         // Adapted from samples at https://developer.android.com/guide/topics/media-apps/audio-focus
378         // Android Auto emulator tests rely on the app pausing and resuming in response to focus
379         // transient loss and focus gain, respectively.
380         switch (focusChange) {
381             case AUDIOFOCUS_GAIN:
382                 if (mResumeOnFocusGain) {
383                     mResumeOnFocusGain = false;
384                     startPlayBack(/* requestAudioFocus= */ false);
385                 }
386                 break;
387             case AUDIOFOCUS_LOSS:
388                 mResumeOnFocusGain = false;
389                 pausePlayback();
390                 break;
391             case AUDIOFOCUS_LOSS_TRANSIENT:
392             case AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
393                 mResumeOnFocusGain = mIsPlaying;
394                 pausePlayback();
395                 break;
396             default:
397                 Log.w(TAG, "Unknown audio focus change " + focusChange);
398         }
399     }
400 }
401