• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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;
18 
19 import static com.android.car.apps.common.util.ViewUtils.showHideViewAnimated;
20 
21 import android.car.content.pm.CarPackageManager;
22 import android.content.Context;
23 import android.support.v4.media.MediaBrowserCompat;
24 import android.util.Log;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.view.inputmethod.InputMethodManager;
28 
29 import androidx.annotation.NonNull;
30 import androidx.annotation.Nullable;
31 import androidx.fragment.app.FragmentActivity;
32 import androidx.lifecycle.Observer;
33 import androidx.lifecycle.ViewModelProviders;
34 import androidx.recyclerview.widget.LinearLayoutManager;
35 import androidx.recyclerview.widget.RecyclerView;
36 
37 import com.android.car.apps.common.util.ViewUtils;
38 import com.android.car.apps.common.util.ViewUtils.ViewAnimEndListener;
39 import com.android.car.arch.common.FutureData;
40 import com.android.car.media.common.MediaItemMetadata;
41 import com.android.car.media.common.browse.MediaBrowserViewModelImpl;
42 import com.android.car.media.common.browse.MediaItemsRepository;
43 import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData;
44 import com.android.car.media.common.source.MediaBrowserConnector.BrowsingState;
45 import com.android.car.media.common.source.MediaSource;
46 import com.android.car.media.widgets.AppBarController;
47 import com.android.car.ui.baselayout.Insets;
48 import com.android.car.ui.toolbar.Toolbar;
49 
50 import java.util.ArrayList;
51 import java.util.Collection;
52 import java.util.HashMap;
53 import java.util.List;
54 import java.util.Map;
55 import java.util.Objects;
56 import java.util.Stack;
57 
58 /**
59  * Controls the views of the {@link MediaActivity}.
60  * TODO: finish moving control code out of MediaActivity (b/179292809).
61  */
62 public class MediaActivityController extends ViewControllerBase {
63 
64     private static final String TAG = "MediaActivityCtr";
65 
66     private final MediaItemsRepository mMediaItemsRepository;
67     private final Callbacks mCallbacks;
68     private final ViewGroup mBrowseArea;
69     private Insets mCarUiInsets;
70     private boolean mPlaybackControlsVisible;
71 
72     private final Map<MediaItemMetadata, BrowseViewController> mBrowseViewControllersByNode =
73             new HashMap<>();
74 
75     // Controllers that should be destroyed once their view is hidden.
76     private final Map<View, BrowseViewController> mBrowseViewControllersToDestroy = new HashMap<>();
77 
78     private final BrowseViewController mRootLoadingController;
79     private final BrowseViewController mSearchResultsController;
80 
81     /**
82      * Stores the reference to {@link MediaActivity.ViewModel#getBrowseStack}.
83      * Updated in {@link #onMediaSourceChanged}.
84      */
85     private Stack<MediaItemMetadata> mBrowseStack;
86     /**
87      * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack}.
88      * Updated in {@link #onMediaSourceChanged}.
89      */
90     private Stack<MediaItemMetadata> mSearchStack;
91     private final MediaActivity.ViewModel mViewModel;
92 
93     private int mRootBrowsableHint;
94     private int mRootPlayableHint;
95     private boolean mBrowseTreeHasChildren;
96     private boolean mAcceptTabSelection = true;
97 
98     /**
99      * Media items to display as tabs. If null, it means we haven't finished loading them yet. If
100      * empty, it means there are no tabs to show
101      */
102     @Nullable
103     private List<MediaItemMetadata> mTopItems;
104 
105     private final Observer<BrowsingState> mMediaBrowsingObserver =
106             this::onMediaBrowsingStateChanged;
107 
108     /**
109      * Callbacks (implemented by the hosting Activity)
110      */
111     public interface Callbacks {
112 
113         /** Invoked when the user clicks on a browsable item. */
onPlayableItemClicked(@onNull MediaItemMetadata item)114         void onPlayableItemClicked(@NonNull MediaItemMetadata item);
115 
116         /** Called once the list of the root node's children has been loaded. */
onRootLoaded()117         void onRootLoaded();
118 
119         /** Returns the activity. */
getActivity()120         FragmentActivity getActivity();
121     }
122 
123     /**
124      * Moves the user one level up in the browse/search tree. Returns whether that was possible.
125      */
navigateBack()126     private boolean navigateBack() {
127         boolean result = false;
128         if (!isAtTopStack()) {
129             hideAndDestroyControllerForItem(getStack().pop());
130 
131             // Show the parent (if any)
132             showCurrentNode(true);
133 
134             if (isAtTopStack() && mViewModel.isSearching()) {
135                 showSearchResults(true);
136             }
137 
138             updateAppBar();
139             result = true;
140         }
141         return result;
142     }
143 
reopenSearch()144     private void reopenSearch() {
145         clearStack(mSearchStack);
146         showSearchResults(true);
147         updateAppBar();
148     }
149 
getActivity()150     private FragmentActivity getActivity() {
151         return mCallbacks.getActivity();
152     }
153 
154     /** Returns the browse or search stack. */
getStack()155     private Stack<MediaItemMetadata> getStack() {
156         return mViewModel.isSearching() ? mSearchStack : mBrowseStack;
157     }
158 
159     /**
160      * @return whether the user is at the top of the browsing stack.
161      */
isAtTopStack()162     private boolean isAtTopStack() {
163         if (mViewModel.isSearching()) {
164             return mSearchStack.isEmpty();
165         } else {
166             // The mBrowseStack stack includes the tab...
167             return mBrowseStack.size() <= 1;
168         }
169     }
170 
clearMediaSource()171     private void clearMediaSource() {
172         showSearchMode(false);
173         for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
174             controller.destroy();
175         }
176         mBrowseViewControllersByNode.clear();
177         mBrowseTreeHasChildren = false;
178     }
179 
updateSearchQuery(@ullable String query)180     private void updateSearchQuery(@Nullable String query) {
181         mMediaItemsRepository.setSearchQuery(query);
182     }
183 
184     /**
185      * Clears search state, removes any UI elements from previous results.
186      */
187     @Override
onMediaSourceChanged(@ullable MediaSource mediaSource)188     void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
189         super.onMediaSourceChanged(mediaSource);
190 
191         updateTabs((mediaSource != null) ? null : new ArrayList<>());
192 
193         mSearchStack = mViewModel.getSearchStack();
194         mBrowseStack = mViewModel.getBrowseStack();
195 
196         updateAppBar();
197     }
198 
onMediaBrowsingStateChanged(BrowsingState newBrowsingState)199     private void onMediaBrowsingStateChanged(BrowsingState newBrowsingState) {
200         switch (newBrowsingState.mConnectionStatus) {
201             case CONNECTING:
202                 break;
203             case CONNECTED:
204                 MediaBrowserCompat browser = newBrowsingState.mBrowser;
205                 mRootBrowsableHint = MediaBrowserViewModelImpl.getRootBrowsableHint(browser);
206                 mRootPlayableHint = MediaBrowserViewModelImpl.getRootPlayableHint(browser);
207 
208                 boolean canSearch = MediaBrowserViewModelImpl.getSupportsSearch(browser);
209                 mAppBarController.setSearchSupported(canSearch);
210                 break;
211 
212             case DISCONNECTING:
213             case REJECTED:
214             case SUSPENDED:
215                 clearMediaSource();
216                 break;
217         }
218 
219         MediaSource savedSource = mViewModel.getBrowsedMediaSource().getValue();
220         MediaSource mediaSource = newBrowsingState.mMediaSource;
221         if (Log.isLoggable(TAG, Log.INFO)) {
222             Log.i(TAG, "MediaSource changed from " + savedSource + " to " + mediaSource);
223         }
224 
225         mViewModel.saveBrowsedMediaSource(mediaSource);
226         onMediaSourceChanged(mediaSource);
227     }
228 
229 
MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo, CarPackageManager carPackageManager, ViewGroup container)230     MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo,
231             CarPackageManager carPackageManager, ViewGroup container) {
232         super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse);
233 
234         FragmentActivity activity = callbacks.getActivity();
235         mCallbacks = callbacks;
236         mMediaItemsRepository = mediaItemsRepo;
237         mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
238         mSearchStack = mViewModel.getSearchStack();
239         mBrowseStack = mViewModel.getBrowseStack();
240         mBrowseArea = mContent.requireViewById(R.id.browse_content_area);
241 
242         MediaItemsLiveData rootMediaItems = mediaItemsRepo.getRootMediaItems();
243         mRootLoadingController = BrowseViewController.newRootController(
244                 mBrowseCallbacks, mBrowseArea, rootMediaItems);
245         mRootLoadingController.getContent().setAlpha(1f);
246 
247         mSearchResultsController = BrowseViewController.newSearchResultsController(
248                 mBrowseCallbacks, mBrowseArea, mMediaItemsRepository.getSearchMediaItems());
249 
250         boolean showingSearch = mViewModel.isShowingSearchResults();
251         ViewUtils.setVisible(mSearchResultsController.getContent(), showingSearch);
252         if (showingSearch) {
253             mSearchResultsController.getContent().setAlpha(1f);
254         }
255 
256         mAppBarController.setListener(mAppBarListener);
257         mAppBarController.setSearchQuery(mViewModel.getSearchQuery());
258         if (mAppBarController.canShowSearchResultsView()) {
259             // TODO(b/180441965) eliminate the need to create a different view and use
260             //     mSearchResultsController.getContent() instead.
261             RecyclerView toolbarSearchResultsView = new RecyclerView(activity);
262             mSearchResultsController.shareBrowseAdapterWith(toolbarSearchResultsView);
263 
264             ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(
265                     ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
266             toolbarSearchResultsView.setLayoutParams(params);
267             toolbarSearchResultsView.setLayoutManager(new LinearLayoutManager(activity));
268             toolbarSearchResultsView.setBackground(
269                     activity.getDrawable(R.drawable.car_ui_ime_wide_screen_background));
270 
271             mAppBarController.setSearchResultsView(toolbarSearchResultsView);
272         }
273 
274         updateAppBar();
275 
276         // Observe forever ensures the caches are destroyed even while the activity isn't resumed.
277         mediaItemsRepo.getBrowsingState().observeForever(mMediaBrowsingObserver);
278 
279         rootMediaItems.observe(activity, this::onRootMediaItemsUpdate);
280         mViewModel.getMiniControlsVisible().observe(activity, this::onPlaybackControlsChanged);
281     }
282 
onDestroy()283     void onDestroy() {
284         mMediaItemsRepository.getBrowsingState().removeObserver(mMediaBrowsingObserver);
285     }
286 
287     private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() {
288         @Override
289         public void onTabSelected(MediaItemMetadata item) {
290             if (mAcceptTabSelection && (item != null) && (item != mViewModel.getSelectedTab())) {
291                 clearStack(mBrowseStack);
292                 mBrowseStack.push(item);
293                 showCurrentNode(true);
294             }
295         }
296 
297         @Override
298         public void onSearchSelection() {
299             if (mViewModel.isSearching()) {
300                 reopenSearch();
301             } else {
302                 showSearchMode(true);
303                 updateAppBar();
304             }
305         }
306 
307         @Override
308         public void onSearch(String query) {
309             if (Log.isLoggable(TAG, Log.DEBUG)) {
310                 Log.d(TAG, "onSearch: " + query);
311             }
312             mViewModel.setSearchQuery(query);
313             updateSearchQuery(query);
314         }
315     };
316 
317     private final BrowseViewController.Callbacks mBrowseCallbacks =
318             new BrowseViewController.Callbacks() {
319         @Override
320         public void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
321             hideKeyboard();
322             mCallbacks.onPlayableItemClicked(item);
323         }
324 
325         @Override
326         public void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
327             hideKeyboard();
328             navigateInto(item);
329         }
330 
331         @Override
332             public void onChildrenNodesRemoved(@NonNull BrowseViewController controller,
333                     @NonNull Collection<MediaItemMetadata> removedNodes) {
334 
335             if (mBrowseStack.contains(controller.getParentItem())) {
336                 for (MediaItemMetadata node : removedNodes) {
337                     int indexOfNode = mBrowseStack.indexOf(node);
338                     if (indexOfNode >= 0) {
339                         clearStack(mBrowseStack.subList(indexOfNode, mBrowseStack.size()));
340                         if (!mViewModel.isShowingSearchResults()) {
341                             showCurrentNode(true);
342                             updateAppBar();
343                         }
344                         break; // The stack contains at most one of the removed nodes.
345                     }
346                 }
347             }
348         }
349 
350         @Override
351         public FragmentActivity getActivity() {
352             return mCallbacks.getActivity();
353         }
354     };
355 
356     private final ViewAnimEndListener mViewAnimEndListener = view -> {
357         BrowseViewController toDestroy = mBrowseViewControllersToDestroy.remove(view);
358         if (toDestroy != null) {
359             toDestroy.destroy();
360         }
361     };
362 
onBackPressed()363     boolean onBackPressed() {
364         boolean success = navigateBack();
365         if (!success && mViewModel.isSearching()) {
366             showSearchMode(false);
367             updateAppBar();
368             return true;
369         }
370         return success;
371     }
372 
browseTreeHasChildren()373     boolean browseTreeHasChildren() {
374         return mBrowseTreeHasChildren;
375     }
376 
navigateInto(@onNull MediaItemMetadata item)377     private void navigateInto(@NonNull MediaItemMetadata item) {
378         showSearchResults(false);
379 
380         // Hide the current node (parent)
381         showCurrentNode(false);
382 
383         // Make item the current node
384         getStack().push(item);
385 
386         // Show the current node (item)
387         showCurrentNode(true);
388 
389         updateAppBar();
390     }
391 
getControllerForItem(@onNull MediaItemMetadata item)392     private BrowseViewController getControllerForItem(@NonNull MediaItemMetadata item) {
393         BrowseViewController controller = mBrowseViewControllersByNode.get(item);
394         if (controller == null) {
395             controller = BrowseViewController.newBrowseController(mBrowseCallbacks, mBrowseArea,
396                     item, mMediaItemsRepository.getMediaChildren(item.getId()), mRootBrowsableHint,
397                     mRootPlayableHint);
398 
399             if (mCarUiInsets != null) {
400                 controller.onCarUiInsetsChanged(mCarUiInsets);
401             }
402             controller.onPlaybackControlsChanged(mPlaybackControlsVisible);
403 
404             mBrowseViewControllersByNode.put(item, controller);
405         }
406         return controller;
407     }
408 
showCurrentNode(boolean show)409     private void showCurrentNode(boolean show) {
410         MediaItemMetadata currentNode = getCurrentMediaItem();
411         if (currentNode == null) {
412             return;
413         }
414         // Only create a controller to show it.
415         BrowseViewController controller = show ? getControllerForItem(currentNode) :
416                 mBrowseViewControllersByNode.get(currentNode);
417 
418         if (controller != null) {
419             showHideViewAnimated(show, controller.getContent(), mFadeDuration,
420                     mViewAnimEndListener);
421         }
422     }
423 
showSearchResults(boolean show)424     private void showSearchResults(boolean show) {
425         if (mViewModel.isShowingSearchResults() != show) {
426             mViewModel.setShowingSearchResults(show);
427             showHideViewAnimated(show, mSearchResultsController.getContent(), mFadeDuration, null);
428         }
429     }
430 
showSearchMode(boolean show)431     private void showSearchMode(boolean show) {
432         if (mViewModel.isSearching() != show) {
433             if (show) {
434                 showCurrentNode(false);
435             }
436 
437             mViewModel.setSearching(show);
438             showSearchResults(show);
439 
440             if (!show) {
441                 showCurrentNode(true);
442             }
443         }
444     }
445 
446     /**
447      * @return the current item being displayed
448      */
449     @Nullable
getCurrentMediaItem()450     private MediaItemMetadata getCurrentMediaItem() {
451         Stack<MediaItemMetadata> stack = getStack();
452         return stack.isEmpty() ? null : stack.lastElement();
453     }
454 
455     @Override
onCarUiInsetsChanged(@onNull Insets insets)456     public void onCarUiInsetsChanged(@NonNull Insets insets) {
457         mCarUiInsets = insets;
458         for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
459             controller.onCarUiInsetsChanged(mCarUiInsets);
460         }
461         mRootLoadingController.onCarUiInsetsChanged(mCarUiInsets);
462         mSearchResultsController.onCarUiInsetsChanged(mCarUiInsets);
463     }
464 
onPlaybackControlsChanged(boolean visible)465     void onPlaybackControlsChanged(boolean visible) {
466         mPlaybackControlsVisible = visible;
467         for (BrowseViewController controller : mBrowseViewControllersByNode.values()) {
468             controller.onPlaybackControlsChanged(mPlaybackControlsVisible);
469         }
470         mRootLoadingController.onPlaybackControlsChanged(mPlaybackControlsVisible);
471         mSearchResultsController.onPlaybackControlsChanged(mPlaybackControlsVisible);
472     }
473 
hideKeyboard()474     private void hideKeyboard() {
475         InputMethodManager in =
476                 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
477         in.hideSoftInputFromWindow(mContent.getWindowToken(), 0);
478     }
479 
hideAndDestroyControllerForItem(@ullable MediaItemMetadata item)480     private void hideAndDestroyControllerForItem(@Nullable MediaItemMetadata item) {
481         if (item == null) {
482             return;
483         }
484         BrowseViewController controller = mBrowseViewControllersByNode.get(item);
485         if (controller == null) {
486             return;
487         }
488 
489         if (controller.getContent().getVisibility() == View.VISIBLE) {
490             View view = controller.getContent();
491             mBrowseViewControllersToDestroy.put(view, controller);
492             showHideViewAnimated(false, view, mFadeDuration, mViewAnimEndListener);
493         } else {
494             controller.destroy();
495         }
496         mBrowseViewControllersByNode.remove(item);
497     }
498 
499     /**
500      * Clears the given stack (or a portion of a stack) and destroys the old controllers (after
501      * their view is hidden).
502      */
clearStack(List<MediaItemMetadata> stack)503     private void clearStack(List<MediaItemMetadata> stack) {
504         for (MediaItemMetadata item : stack) {
505             hideAndDestroyControllerForItem(item);
506         }
507         stack.clear();
508     }
509 
510     /**
511      * Updates the tabs displayed on the app bar, based on the top level items on the browse tree.
512      * If there is at least one browsable item, we show the browse content of that node. If there
513      * are only playable items, then we show those items. If there are not items at all, we show the
514      * empty message. If we receive null, we show the error message.
515      *
516      * @param items top level items, null if the items are still being loaded, or empty list if
517      *              items couldn't be loaded.
518      */
updateTabs(@ullable List<MediaItemMetadata> items)519     private void updateTabs(@Nullable List<MediaItemMetadata> items) {
520         if (Objects.equals(mTopItems, items)) {
521             // When coming back to the app, the live data sends an update even if the list hasn't
522             // changed. Updating the tabs then recreates the browse view, which produces jank
523             // (b/131830876), and also resets the navigation to the top of the first tab...
524             return;
525         }
526         mTopItems = items;
527 
528         if (mTopItems == null || mTopItems.isEmpty()) {
529             mAppBarController.setItems(null);
530             mAppBarController.setActiveItem(null);
531             if (items != null) {
532                 // Only do this when not loading the tabs or we loose the saved one.
533                 clearStack(mBrowseStack);
534             }
535             updateAppBar();
536             return;
537         }
538 
539         MediaItemMetadata oldTab = mViewModel.getSelectedTab();
540         MediaItemMetadata newTab = items.contains(oldTab) ? oldTab : items.get(0);
541 
542         try {
543             mAcceptTabSelection = false;
544             mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems);
545             mAppBarController.setActiveItem(newTab);
546 
547             if (oldTab != newTab) {
548                 // Tabs belong to the browse stack.
549                 clearStack(mBrowseStack);
550                 mBrowseStack.push(newTab);
551             }
552 
553             if (!mViewModel.isShowingSearchResults()) {
554                 // Needed when coming back to an app after a config change or from another app,
555                 // or when the tab actually changes.
556                 showCurrentNode(true);
557             }
558         }  finally {
559             mAcceptTabSelection = true;
560         }
561         updateAppBar();
562     }
563 
updateAppBarTitle()564     private void updateAppBarTitle() {
565         boolean isStacked = !isAtTopStack();
566 
567         final CharSequence title;
568         if (isStacked) {
569             // If not at top level, show the current item as title
570             title = getCurrentMediaItem().getTitle();
571         } else if (mTopItems == null) {
572             // If still loading the tabs, force to show an empty bar.
573             title = "";
574         } else if (mTopItems.size() == 1) {
575             // If we finished loading tabs and there is only one, use that as title.
576             title = mTopItems.get(0).getTitle();
577         } else {
578             // Otherwise (no tabs or more than 1 tabs), show the current media source title.
579             MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
580             title = getAppBarDefaultTitle(mediaSource);
581         }
582 
583         mAppBarController.setTitle(title);
584     }
585 
586     /**
587      * Update elements of the appbar that change depending on where we are in the browse.
588      */
updateAppBar()589     private void updateAppBar() {
590         boolean isSearching = mViewModel.isSearching();
591         boolean isStacked = !isAtTopStack();
592         if (Log.isLoggable(TAG, Log.DEBUG)) {
593             Log.d(TAG, "App bar is in stacked state: " + isStacked);
594         }
595         Toolbar.State unstackedState = isSearching ? Toolbar.State.SEARCH : Toolbar.State.HOME;
596         updateAppBarTitle();
597         mAppBarController.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
598         mAppBarController.showSearchIfSupported(!isSearching || isStacked);
599     }
600 
onRootMediaItemsUpdate(FutureData<List<MediaItemMetadata>> data)601     private void onRootMediaItemsUpdate(FutureData<List<MediaItemMetadata>> data) {
602         if (data.isLoading()) {
603             if (Log.isLoggable(TAG, Log.INFO)) {
604                 Log.i(TAG, "Loading browse tree...");
605             }
606             mBrowseTreeHasChildren = false;
607             updateTabs(null);
608             return;
609         }
610 
611         List<MediaItemMetadata> items =
612                 MediaBrowserViewModelImpl.filterItems(/*forRoot*/ true, data.getData());
613 
614         boolean browseTreeHasChildren = items != null && !items.isEmpty();
615         if (Log.isLoggable(TAG, Log.INFO)) {
616             Log.i(TAG, "Browse tree loaded, status (has children or not) changed: "
617                     + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren);
618         }
619         mBrowseTreeHasChildren = browseTreeHasChildren;
620         mCallbacks.onRootLoaded();
621         updateTabs(items != null ? items : new ArrayList<>());
622     }
623 
624 }
625