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