1 /*
2  * Copyright (C) 2016 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 package com.android.car.media;
17 
18 import static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_BROWSE;
19 
20 import static com.android.car.apps.common.util.VectorMath.EPSILON;
21 
22 import android.annotation.SuppressLint;
23 import android.app.AlertDialog;
24 import android.app.Application;
25 import android.app.PendingIntent;
26 import android.car.Car;
27 import android.car.content.pm.CarPackageManager;
28 import android.car.drivingstate.CarUxRestrictions;
29 import android.content.ComponentName;
30 import android.content.Context;
31 import android.content.Intent;
32 import android.content.res.Resources;
33 import android.os.Bundle;
34 import android.support.v4.media.session.PlaybackStateCompat;
35 import android.text.TextUtils;
36 import android.util.Log;
37 import android.util.Size;
38 import android.view.GestureDetector;
39 import android.view.MotionEvent;
40 import android.view.View;
41 import android.view.ViewConfiguration;
42 import android.view.ViewGroup;
43 import android.widget.Toast;
44 
45 import androidx.annotation.NonNull;
46 import androidx.annotation.Nullable;
47 import androidx.core.view.GestureDetectorCompat;
48 import androidx.fragment.app.FragmentActivity;
49 import androidx.lifecycle.AndroidViewModel;
50 import androidx.lifecycle.LiveData;
51 import androidx.lifecycle.MutableLiveData;
52 import androidx.lifecycle.ViewModelProviders;
53 
54 import com.android.car.apps.common.util.VectorMath;
55 import com.android.car.apps.common.util.CarPackageManagerUtils;
56 import com.android.car.apps.common.util.ViewUtils;
57 import com.android.car.media.common.MediaConstants;
58 import com.android.car.media.common.MediaItemMetadata;
59 import com.android.car.media.common.MinimizedPlaybackControlBar;
60 import com.android.car.media.common.playback.PlaybackViewModel;
61 import com.android.car.media.common.source.MediaSource;
62 import com.android.car.media.common.source.MediaSourceViewModel;
63 import com.android.car.ui.AlertDialogBuilder;
64 import com.android.car.ui.utils.CarUxRestrictionsUtil;
65 
66 import java.util.Collections;
67 import java.util.HashMap;
68 import java.util.Map;
69 import java.util.Stack;
70 
71 /**
72  * This activity controls the UI of media. It also updates the connection status for the media app
73  * by broadcast.
74  */
75 public class MediaActivity extends FragmentActivity implements BrowseViewController.Callbacks {
76     private static final String TAG = "MediaActivity";
77 
78     /** Configuration (controlled from resources) */
79     private int mFadeDuration;
80 
81     /** Models */
82     private PlaybackViewModel.PlaybackController mPlaybackController;
83 
84     /** Layout views */
85     private View mRootView;
86     private PlaybackFragment mPlaybackFragment;
87     private BrowseViewController mSearchController;
88     private BrowseViewController mBrowseController;
89     private MinimizedPlaybackControlBar mMiniPlaybackControls;
90     private ViewGroup mBrowseContainer;
91     private ViewGroup mPlaybackContainer;
92     private ViewGroup mErrorContainer;
93     private ErrorViewController mErrorController;
94     private ViewGroup mSearchContainer;
95 
96     private Toast mToast;
97     private AlertDialog mDialog;
98 
99     /** Current state */
100     private Mode mMode;
101     private boolean mCanShowMiniPlaybackControls;
102     private PlaybackViewModel.PlaybackStateWrapper mCurrentPlaybackStateWrapper;
103 
104     private Car mCar;
105     private CarPackageManager mCarPackageManager;
106 
107     private float mCloseVectorX;
108     private float mCloseVectorY;
109     private float mCloseVectorNorm;
110 
111     private CarUxRestrictionsUtil mCarUxRestrictionsUtil;
112     private CarUxRestrictions mActiveCarUxRestrictions;
113     @CarUxRestrictions.CarUxRestrictionsInfo
114     private int mRestrictions;
115     private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener =
116             (carUxRestrictions) -> mActiveCarUxRestrictions = carUxRestrictions;
117 
118 
119     private PlaybackFragment.PlaybackFragmentListener mPlaybackFragmentListener =
120             () -> changeMode(Mode.BROWSING);
121 
122     /**
123      * Possible modes of the application UI
124      */
125     enum Mode {
126         /** The user is browsing a media source */
127         BROWSING,
128         /** The user is interacting with the full screen playback UI */
129         PLAYBACK,
130         /** The user is searching within a media source */
131         SEARCHING,
132         /** There's no browse tree and playback doesn't work. */
133         FATAL_ERROR
134     }
135 
136     private static final Map<Integer, Integer> ERROR_CODE_MESSAGES_MAP;
137 
138     static {
139         Map<Integer, Integer> map = new HashMap<>();
map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error)140         map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error);
map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported)141         map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported);
map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, R.string.error_code_authentication_expired)142         map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED,
143                 R.string.error_code_authentication_expired);
map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, R.string.error_code_premium_account_required)144         map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED,
145                 R.string.error_code_premium_account_required);
map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, R.string.error_code_concurrent_stream_limit)146         map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT,
147                 R.string.error_code_concurrent_stream_limit);
map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, R.string.error_code_parental_control_restricted)148         map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED,
149                 R.string.error_code_parental_control_restricted);
map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, R.string.error_code_not_available_in_region)150         map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION,
151                 R.string.error_code_not_available_in_region);
map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, R.string.error_code_content_already_playing)152         map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING,
153                 R.string.error_code_content_already_playing);
map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, R.string.error_code_skip_limit_reached)154         map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED,
155                 R.string.error_code_skip_limit_reached);
map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted)156         map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted);
map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue)157         map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue);
158         ERROR_CODE_MESSAGES_MAP = Collections.unmodifiableMap(map);
159     }
160 
161     @Override
onCreate(Bundle savedInstanceState)162     protected void onCreate(Bundle savedInstanceState) {
163         super.onCreate(savedInstanceState);
164         setContentView(R.layout.media_activity);
165 
166         Resources res = getResources();
167         mCloseVectorX = res.getFloat(R.dimen.media_activity_close_vector_x);
168         mCloseVectorY = res.getFloat(R.dimen.media_activity_close_vector_y);
169         mCloseVectorNorm = VectorMath.norm2(mCloseVectorX, mCloseVectorY);
170 
171 
172         MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel();
173         // TODO(b/151174811): Use appropriate modes, instead of just MEDIA_SOURCE_MODE_BROWSE
174         PlaybackViewModel playbackViewModel = getPlaybackViewModel();
175         ViewModel localViewModel = getInnerViewModel();
176         // We can't rely on savedInstanceState to determine whether the model has been initialized
177         // as on a config change savedInstanceState != null and the model is initialized, but if
178         // the app was killed by the system then savedInstanceState != null and the model is NOT
179         // initialized...
180         if (localViewModel.needsInitialization()) {
181             localViewModel.init(playbackViewModel);
182         }
183         mMode = localViewModel.getSavedMode();
184 
185         mRootView = findViewById(R.id.media_activity_root);
186 
187         mediaSourceViewModel.getPrimaryMediaSource().observe(this,
188                 this::onMediaSourceChanged);
189 
190         mPlaybackFragment = new PlaybackFragment();
191         mPlaybackFragment.setListener(mPlaybackFragmentListener);
192 
193 
194         Size maxArtSize = MediaAppConfig.getMediaItemsBitmapMaxSize(this);
195         mMiniPlaybackControls = findViewById(R.id.minimized_playback_controls);
196         mMiniPlaybackControls.setModel(playbackViewModel, this, maxArtSize);
197         mMiniPlaybackControls.setOnClickListener(view -> changeMode(Mode.PLAYBACK));
198 
199         mFadeDuration = res.getInteger(R.integer.new_album_art_fade_in_duration);
200         mBrowseContainer = findViewById(R.id.fragment_container);
201         mErrorContainer = findViewById(R.id.error_container);
202         mPlaybackContainer = findViewById(R.id.playback_container);
203         mSearchContainer = findViewById(R.id.search_container);
204         getSupportFragmentManager().beginTransaction()
205                 .replace(R.id.playback_container, mPlaybackFragment)
206                 .commit();
207 
208         mBrowseController = BrowseViewController.newInstance(this,
209                 mCarPackageManager, mBrowseContainer);
210         mSearchController = BrowseViewController.newSearchInstance(this,
211                 mCarPackageManager, mSearchContainer);
212 
213         playbackViewModel.getPlaybackController().observe(this,
214                 playbackController -> {
215                     if (playbackController != null) playbackController.prepare();
216                     mPlaybackController = playbackController;
217                 });
218 
219         playbackViewModel.getPlaybackStateWrapper().observe(this,
220                 state -> handlePlaybackState(state, true));
221 
222         mCar = Car.createCar(this);
223         mCarPackageManager = (CarPackageManager) mCar.getCarManager(Car.PACKAGE_SERVICE);
224 
225         mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(this);
226         mRestrictions = CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP;
227         mCarUxRestrictionsUtil.register(mListener);
228 
229         mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this));
230 
231         localViewModel.getMiniControlsVisible().observe(this, visible -> {
232             mBrowseController.onPlaybackControlsChanged(visible);
233             mSearchController.onPlaybackControlsChanged(visible);
234         });
235     }
236 
237     @Override
onDestroy()238     protected void onDestroy() {
239         mCarUxRestrictionsUtil.unregister(mListener);
240         mCar.disconnect();
241         super.onDestroy();
242     }
243 
isUxRestricted()244     private boolean isUxRestricted() {
245         return CarUxRestrictionsUtil.isRestricted(mRestrictions, mActiveCarUxRestrictions);
246     }
247 
handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, boolean ignoreSameState)248     private void handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state,
249             boolean ignoreSameState) {
250         if (Log.isLoggable(TAG, Log.DEBUG)) {
251             Log.d(TAG,
252                     "handlePlaybackState(); state change: " + (mCurrentPlaybackStateWrapper != null
253                             ? mCurrentPlaybackStateWrapper.getState() : null) + " -> " + (
254                             state != null ? state.getState() : null));
255 
256         }
257 
258         // TODO(arnaudberry) rethink interactions between customized layouts and dynamic visibility.
259         mCanShowMiniPlaybackControls = (state != null) && state.shouldDisplay();
260         updateMiniPlaybackControls(true);
261 
262         if (state == null) {
263             mCurrentPlaybackStateWrapper = null;
264             return;
265         }
266 
267         String displayedMessage = getDisplayedMessage(state);
268         if (Log.isLoggable(TAG, Log.DEBUG)) {
269             Log.d(TAG, "Displayed error message: [" + displayedMessage + "]");
270         }
271         if (ignoreSameState && mCurrentPlaybackStateWrapper != null
272                 && mCurrentPlaybackStateWrapper.getState() == state.getState()
273                 && TextUtils.equals(displayedMessage,
274                 getDisplayedMessage(mCurrentPlaybackStateWrapper))) {
275             if (Log.isLoggable(TAG, Log.DEBUG)) {
276                 Log.d(TAG, "Ignore same playback state.");
277             }
278             return;
279         }
280 
281         mCurrentPlaybackStateWrapper = state;
282 
283         maybeCancelToast();
284         maybeCancelDialog();
285 
286         Bundle extras = state.getExtras();
287         PendingIntent intent = extras == null ? null : extras.getParcelable(
288                 MediaConstants.ERROR_RESOLUTION_ACTION_INTENT);
289         String label = extras == null ? null : extras.getString(
290                 MediaConstants.ERROR_RESOLUTION_ACTION_LABEL);
291 
292         boolean isFatalError = false;
293         if (!TextUtils.isEmpty(displayedMessage)) {
294             if (mBrowseController.browseTreeHasChildren()) {
295                 if (intent != null && !isUxRestricted()) {
296                     showDialog(intent, displayedMessage, label, getString(android.R.string.cancel));
297                 } else {
298                     showToast(displayedMessage);
299                 }
300             } else {
301                 getErrorController().setError(displayedMessage, label, intent,
302                         CarPackageManagerUtils.isDistractionOptimized(mCarPackageManager, intent));
303                 isFatalError = true;
304             }
305         }
306         if (isFatalError) {
307             changeMode(Mode.FATAL_ERROR);
308         } else if (mMode == Mode.FATAL_ERROR) {
309             changeMode(Mode.BROWSING);
310         }
311     }
312 
getErrorController()313     private ErrorViewController getErrorController() {
314         if (mErrorController == null) {
315             mErrorController = new ErrorViewController(this, mCarPackageManager, mErrorContainer);
316             MediaSource mediaSource = getMediaSourceViewModel().getPrimaryMediaSource().getValue();
317             mErrorController.onMediaSourceChanged(mediaSource);
318         }
319         return mErrorController;
320     }
321 
getDisplayedMessage(@ullable PlaybackViewModel.PlaybackStateWrapper state)322     private String getDisplayedMessage(@Nullable PlaybackViewModel.PlaybackStateWrapper state) {
323         if (state == null) {
324             return null;
325         }
326         if (!TextUtils.isEmpty(state.getErrorMessage())) {
327             return state.getErrorMessage().toString();
328         }
329         // ERROR_CODE_UNKNOWN_ERROR means there is no error in PlaybackState.
330         if (state.getErrorCode() != PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR) {
331             Integer messageId = ERROR_CODE_MESSAGES_MAP.get(state.getErrorCode());
332             return messageId != null ? getString(messageId) : getString(
333                     R.string.default_error_message);
334         }
335         if (state.getState() == PlaybackStateCompat.STATE_ERROR) {
336             return getString(R.string.default_error_message);
337         }
338         return null;
339     }
340 
showDialog(PendingIntent intent, String message, String positiveBtnText, String negativeButtonText)341     private void showDialog(PendingIntent intent, String message, String positiveBtnText,
342             String negativeButtonText) {
343         AlertDialogBuilder dialog = new AlertDialogBuilder(this);
344         mDialog = dialog.setMessage(message)
345                 .setNegativeButton(negativeButtonText, null)
346                 .setPositiveButton(positiveBtnText, (dialogInterface, i) -> {
347                     try {
348                         intent.send();
349                     } catch (PendingIntent.CanceledException e) {
350                         if (Log.isLoggable(TAG, Log.ERROR)) {
351                             Log.e(TAG, "Pending intent canceled");
352                         }
353                     }
354                 })
355                 .show();
356     }
357 
maybeCancelDialog()358     private void maybeCancelDialog() {
359         if (mDialog != null) {
360             mDialog.cancel();
361             mDialog = null;
362         }
363     }
364 
showToast(String message)365     private void showToast(String message) {
366         mToast = Toast.makeText(this, message, Toast.LENGTH_LONG);
367         mToast.show();
368     }
369 
maybeCancelToast()370     private void maybeCancelToast() {
371         if (mToast != null) {
372             mToast.cancel();
373             mToast = null;
374         }
375     }
376 
377     @Override
onBackPressed()378     public void onBackPressed() {
379         switch (mMode) {
380             case PLAYBACK:
381                 changeMode(Mode.BROWSING);
382                 break;
383             case SEARCHING:
384                 mSearchController.onBackPressed();
385                 break;
386             case BROWSING:
387                 boolean handled = mBrowseController.onBackPressed();
388                 if (handled) return;
389                 // Fall through.
390             case FATAL_ERROR:
391             default:
392                 super.onBackPressed();
393         }
394     }
395 
396     /**
397      * Sets the media source being browsed.
398      *
399      * @param mediaSource the new media source we are going to try to browse
400      */
onMediaSourceChanged(@ullable MediaSource mediaSource)401     private void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
402         ComponentName savedMediaSource = getInnerViewModel().getSavedMediaSource();
403         if (Log.isLoggable(TAG, Log.INFO)) {
404             Log.i(TAG, "MediaSource changed from " + savedMediaSource + " to " + mediaSource);
405         }
406 
407         savedMediaSource = mediaSource != null ? mediaSource.getBrowseServiceComponentName() : null;
408         getInnerViewModel().saveMediaSource(savedMediaSource);
409 
410         mBrowseController.onMediaSourceChanged(mediaSource);
411         mSearchController.onMediaSourceChanged(mediaSource);
412         if (mErrorController != null) {
413             mErrorController.onMediaSourceChanged(mediaSource);
414         }
415 
416         mCurrentPlaybackStateWrapper = null;
417         maybeCancelToast();
418         maybeCancelDialog();
419         if (mediaSource != null) {
420             if (Log.isLoggable(TAG, Log.INFO)) {
421                 Log.i(TAG, "Browsing: " + mediaSource.getDisplayName());
422             }
423             Mode mediaSourceMode = getInnerViewModel().getSavedMode();
424             // Changes the mode regardless of its previous value so that the views can be updated.
425             changeModeInternal(mediaSourceMode, false);
426 
427             // Always go through the trampoline activity to keep all the dispatching logic there.
428             startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE));
429         }
430     }
431 
432     @Override
changeMode(Mode mode)433     public void changeMode(Mode mode) {
434         if (mMode == mode) {
435             if (Log.isLoggable(TAG, Log.INFO)) {
436                 Log.i(TAG, "Mode " + mMode + " change is ignored");
437             }
438             return;
439         }
440         changeModeInternal(mode, true);
441     }
442 
changeModeInternal(Mode mode, boolean hideViewAnimated)443     private void changeModeInternal(Mode mode, boolean hideViewAnimated) {
444         if (Log.isLoggable(TAG, Log.INFO)) {
445             Log.i(TAG, "Changing mode from: " + mMode + " to: " + mode);
446         }
447         int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
448 
449         Mode oldMode = mMode;
450         getInnerViewModel().saveMode(mode);
451         mMode = mode;
452 
453         mPlaybackFragment.closeOverflowMenu();
454         updateMiniPlaybackControls(hideViewAnimated);
455 
456         switch (mMode) {
457             case FATAL_ERROR:
458                 ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration);
459                 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
460                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
461                 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
462                 break;
463             case PLAYBACK:
464                 mPlaybackContainer.setX(0);
465                 mPlaybackContainer.setY(0);
466                 mPlaybackContainer.setAlpha(0f);
467                 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
468                 ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration);
469                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
470                 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
471                 break;
472             case BROWSING:
473                 if (oldMode == Mode.PLAYBACK) {
474                     ViewUtils.hideViewAnimated(mErrorContainer, 0);
475                     ViewUtils.showViewAnimated(mBrowseContainer, 0);
476                     ViewUtils.hideViewAnimated(mSearchContainer, 0);
477                     animateOutPlaybackContainer(fadeOutDuration);
478                 } else {
479                     ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
480                     ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
481                     ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration);
482                     ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
483                 }
484                 break;
485             case SEARCHING:
486                 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
487                 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
488                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
489                 ViewUtils.showViewAnimated(mSearchContainer, mFadeDuration);
490                 break;
491         }
492     }
493 
animateOutPlaybackContainer(int fadeOutDuration)494     private void animateOutPlaybackContainer(int fadeOutDuration) {
495         if (mCloseVectorNorm <= EPSILON) {
496             ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
497             return;
498         }
499 
500         // Assumption: mPlaybackContainer shares 1 edge with the side of the screen the
501         // slide animation brings it towards to. Since only vertical and horizontal translations
502         // are supported mPlaybackContainer only needs to move by its width or its height to be
503         // hidden.
504 
505         // Use width and height with and extra pixel for safety.
506         float w = mPlaybackContainer.getWidth() + 1;
507         float h = mPlaybackContainer.getHeight() + 1;
508 
509         float tX = 0.0f;
510         float tY = 0.0f;
511         if (Math.abs(mCloseVectorY) <= EPSILON) {
512             // Only moving horizontally
513             tX = mCloseVectorX * w / mCloseVectorNorm;
514         } else if (Math.abs(mCloseVectorX) <= EPSILON) {
515             // Only moving vertically
516             tY = mCloseVectorY * h / mCloseVectorNorm;
517         } else {
518             if (Log.isLoggable(TAG, Log.DEBUG)) {
519                 Log.d(TAG, "The vector to close the playback container must be vertical or"
520                         + " horizontal");
521             }
522             ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
523             return;
524         }
525 
526         mPlaybackContainer.animate()
527                 .translationX(tX)
528                 .translationY(tY)
529                 .setDuration(fadeOutDuration)
530                 .setListener(ViewUtils.hideViewAfterAnimation(mPlaybackContainer))
531                 .start();
532     }
533 
updateMiniPlaybackControls(boolean hideViewAnimated)534     private void updateMiniPlaybackControls(boolean hideViewAnimated) {
535         int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
536         // Minimized control bar should be hidden in playback view.
537         final boolean shouldShowMiniPlaybackControls =
538                 mCanShowMiniPlaybackControls && mMode != Mode.PLAYBACK;
539         if (shouldShowMiniPlaybackControls) {
540             ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration);
541         } else {
542             ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration);
543         }
544         getInnerViewModel().setMiniControlsVisible(shouldShowMiniPlaybackControls);
545     }
546 
547     @Override
onPlayableItemClicked(MediaItemMetadata item)548     public void onPlayableItemClicked(MediaItemMetadata item) {
549         mPlaybackController.playItem(item);
550         boolean switchToPlayback = getResources().getBoolean(
551                 R.bool.switch_to_playback_view_when_playable_item_is_clicked);
552         if (switchToPlayback) {
553             changeMode(Mode.PLAYBACK);
554         } else if (mMode == Mode.SEARCHING) {
555             changeMode(Mode.BROWSING);
556         }
557         setIntent(null);
558     }
559 
560     @Override
onRootLoaded()561     public void onRootLoaded() {
562         PlaybackViewModel playbackViewModel = getPlaybackViewModel();
563         handlePlaybackState(playbackViewModel.getPlaybackStateWrapper().getValue(), false);
564     }
565 
566     @Override
getActivity()567     public FragmentActivity getActivity() {
568         return this;
569     }
570 
getMediaSourceViewModel()571     private MediaSourceViewModel getMediaSourceViewModel() {
572         return MediaSourceViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
573     }
574 
getPlaybackViewModel()575     private PlaybackViewModel getPlaybackViewModel() {
576         return PlaybackViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
577     }
578 
getInnerViewModel()579     private ViewModel getInnerViewModel() {
580         return ViewModelProviders.of(this).get(ViewModel.class);
581     }
582 
583     public static class ViewModel extends AndroidViewModel {
584 
585         static class MediaServiceState {
586             Mode mMode = Mode.BROWSING;
587             Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
588             Stack<MediaItemMetadata> mSearchStack = new Stack<>();
589             String mSearchQuery;
590             boolean mQueueVisible = false;
591         }
592 
593         private boolean mNeedsInitialization = true;
594         private PlaybackViewModel mPlaybackViewModel;
595         private ComponentName mMediaSource;
596         private final Map<ComponentName, MediaServiceState> mStates = new HashMap<>();
597         private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>();
598 
ViewModel(@onNull Application application)599         public ViewModel(@NonNull Application application) {
600             super(application);
601         }
602 
init(@onNull PlaybackViewModel playbackViewModel)603         void init(@NonNull PlaybackViewModel playbackViewModel) {
604             if (mPlaybackViewModel == playbackViewModel) {
605                 return;
606             }
607             mPlaybackViewModel = playbackViewModel;
608             mNeedsInitialization = false;
609         }
610 
needsInitialization()611         boolean needsInitialization() {
612             return mNeedsInitialization;
613         }
614 
setMiniControlsVisible(boolean visible)615         void setMiniControlsVisible(boolean visible) {
616             mIsMiniControlsVisible.setValue(visible);
617         }
618 
getMiniControlsVisible()619         LiveData<Boolean> getMiniControlsVisible() {
620             return mIsMiniControlsVisible;
621         }
622 
getSavedState()623         MediaServiceState getSavedState() {
624             MediaServiceState state = mStates.get(mMediaSource);
625             if (state == null) {
626                 state = new MediaServiceState();
627                 mStates.put(mMediaSource, state);
628             }
629             return state;
630         }
631 
saveMode(Mode mode)632         void saveMode(Mode mode) {
633             getSavedState().mMode = mode;
634         }
635 
getSavedMode()636         Mode getSavedMode() {
637             return getSavedState().mMode;
638         }
639 
640         @Nullable
getSelectedTab()641         MediaItemMetadata getSelectedTab() {
642             Stack<MediaItemMetadata> stack = getSavedState().mBrowseStack;
643             return (stack != null && !stack.empty()) ? stack.firstElement() : null;
644         }
645 
setQueueVisible(boolean visible)646         void setQueueVisible(boolean visible) {
647             getSavedState().mQueueVisible = visible;
648         }
649 
getQueueVisible()650         boolean getQueueVisible() {
651             return getSavedState().mQueueVisible;
652         }
653 
saveMediaSource(ComponentName mediaSource)654         void saveMediaSource(ComponentName mediaSource) {
655             mMediaSource = mediaSource;
656         }
657 
getSavedMediaSource()658         ComponentName getSavedMediaSource() {
659             return mMediaSource;
660         }
661 
getBrowseStack()662         Stack<MediaItemMetadata> getBrowseStack() {
663             return getSavedState().mBrowseStack;
664         }
665 
getSearchStack()666         Stack<MediaItemMetadata> getSearchStack() {
667             return getSavedState().mSearchStack;
668         }
669 
getSearchQuery()670         String getSearchQuery() {
671             return getSavedState().mSearchQuery;
672         }
673 
setSearchQuery(String searchQuery)674         void setSearchQuery(String searchQuery) {
675             getSavedState().mSearchQuery = searchQuery;
676         }
677     }
678 
679     private class ClosePlaybackDetector extends GestureDetector.SimpleOnGestureListener
680             implements View.OnTouchListener {
681 
682         private static final float COS_30 = 0.866f;
683 
684         private final ViewConfiguration mViewConfig;
685         private final GestureDetectorCompat mDetector;
686 
687 
ClosePlaybackDetector(Context context)688         ClosePlaybackDetector(Context context) {
689             mViewConfig = ViewConfiguration.get(context);
690             mDetector = new GestureDetectorCompat(context, this);
691         }
692 
693         @SuppressLint("ClickableViewAccessibility")
694         @Override
onTouch(View v, MotionEvent event)695         public boolean onTouch(View v, MotionEvent event) {
696             return mDetector.onTouchEvent(event);
697         }
698 
699         @Override
onDown(MotionEvent event)700         public boolean onDown(MotionEvent event) {
701             return (mMode == Mode.PLAYBACK) && (mCloseVectorNorm > EPSILON);
702         }
703 
704         @Override
onFling(MotionEvent e1, MotionEvent e2, float vX, float vY)705         public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) {
706             float moveX = e2.getX() - e1.getX();
707             float moveY = e2.getY() - e1.getY();
708             float moveVectorNorm = VectorMath.norm2(moveX, moveY);
709             if (moveVectorNorm > mViewConfig.getScaledTouchSlop() &&
710                     VectorMath.norm2(vX, vY) > mViewConfig.getScaledMinimumFlingVelocity()) {
711                 float dot = VectorMath.dotProduct(mCloseVectorX, mCloseVectorY, moveX, moveY);
712                 float cos = dot / (mCloseVectorNorm * moveVectorNorm);
713                 if (cos >= COS_30) { // Accept 30 degrees on each side of the close vector.
714                     changeMode(Mode.BROWSING);
715                 }
716             }
717             return true;
718         }
719     }
720 }
721