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