1 /*
2  * Copyright 2018 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.arch.common.LiveDataFunctions.ifThenElse;
20 
21 import android.car.content.pm.CarPackageManager;
22 import android.content.Context;
23 import android.content.res.Resources;
24 import android.os.Handler;
25 import android.text.TextUtils;
26 import android.util.Log;
27 import android.view.ViewGroup;
28 import android.view.inputmethod.InputMethodManager;
29 import android.widget.ImageView;
30 import android.widget.TextView;
31 
32 import androidx.annotation.NonNull;
33 import androidx.annotation.Nullable;
34 import androidx.fragment.app.FragmentActivity;
35 import androidx.lifecycle.LiveData;
36 import androidx.lifecycle.MutableLiveData;
37 import androidx.lifecycle.ViewModelProviders;
38 import androidx.recyclerview.widget.RecyclerView;
39 
40 import com.android.car.apps.common.util.ViewUtils;
41 import com.android.car.arch.common.FutureData;
42 import com.android.car.media.browse.BrowseAdapter;
43 import com.android.car.media.common.GridSpacingItemDecoration;
44 import com.android.car.media.common.MediaItemMetadata;
45 import com.android.car.media.common.browse.MediaBrowserViewModel;
46 import com.android.car.media.common.source.MediaSource;
47 import com.android.car.media.widgets.AppBarView;
48 import com.android.car.ui.toolbar.Toolbar;
49 
50 import java.util.ArrayList;
51 import java.util.List;
52 import java.util.Objects;
53 import java.util.Stack;
54 import java.util.function.Predicate;
55 import java.util.stream.Collectors;
56 
57 /**
58  * A view controller that implements the content forward browsing experience.
59  *
60  * This can be used to display either search or browse results at the root level. Deeper levels will
61  * be handled the same way between search and browse, using a back stack to return to the root.
62  */
63 public class BrowseViewController extends ViewControllerBase {
64     private static final String TAG = "BrowseViewController";
65 
66     private static final String REGULAR_BROWSER_VIEW_MODEL_KEY
67             = "com.android.car.media.regular_browser_view_model";
68     private static final String SEARCH_BROWSER_VIEW_MODEL_KEY
69             = "com.android.car.media.search_browser_view_model";
70 
71     private final Callbacks mCallbacks;
72 
73     private final RecyclerView mBrowseList;
74     private final ImageView mErrorIcon;
75     private final TextView mMessage;
76     private final BrowseAdapter mBrowseAdapter;
77     private String mSearchQuery;
78     private final int mFadeDuration;
79     private final int mLoadingIndicatorDelay;
80     private final boolean mIsSearchController;
81     private final MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
82     private final Handler mHandler = new Handler();
83     /**
84      * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack} or to
85      * {@link MediaActivity.ViewModel#getBrowseStack}. Updated in {@link #onMediaSourceChanged}.
86      */
87     private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
88     private final MediaActivity.ViewModel mViewModel;
89     private final MediaBrowserViewModel mRootMediaBrowserViewModel;
90     private final MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
91     private final BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
92 
93         @Override
94         protected void onPlayableItemClicked(MediaItemMetadata item) {
95             hideKeyboard();
96             getParent().onPlayableItemClicked(item);
97         }
98 
99         @Override
100         protected void onBrowsableItemClicked(MediaItemMetadata item) {
101             hideKeyboard();
102             navigateInto(item);
103         }
104     };
105 
106     private boolean mBrowseTreeHasChildren;
107     private boolean mAcceptTabSelection = true;
108 
109     /**
110      * Media items to display as tabs. If null, it means we haven't finished loading them yet. If
111      * empty, it means there are no tabs to show
112      */
113     @Nullable
114     private List<MediaItemMetadata> mTopItems;
115 
116     /**
117      * Callbacks (implemented by the hosting Activity)
118      */
119     public interface Callbacks {
120         /**
121          * Method invoked when the user clicks on a playable item
122          *
123          * @param item item to be played.
124          */
onPlayableItemClicked(MediaItemMetadata item)125         void onPlayableItemClicked(MediaItemMetadata item);
126 
127         /** Called once the list of the root node's children has been loaded. */
onRootLoaded()128         void onRootLoaded();
129 
130         /** Change to a new UI mode. */
changeMode(MediaActivity.Mode mode)131         void changeMode(MediaActivity.Mode mode);
132 
getActivity()133         FragmentActivity getActivity();
134     }
135 
136     /**
137      * Moves the user one level up in the browse tree. Returns whether that was possible.
138      */
navigateBack()139     private boolean navigateBack() {
140         boolean result = false;
141         if (!isAtTopStack()) {
142             mBrowseStack.pop();
143             mMediaBrowserViewModel.search(mSearchQuery);
144             mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
145             updateAppBar();
146             result = true;
147         }
148         if (isAtTopStack()) {
149             mShowSearchResults.setValue(mIsSearchController);
150         }
151         return result;
152     }
153 
reopenSearch()154     private void reopenSearch() {
155         if (mIsSearchController) {
156             mBrowseStack.clear();
157             updateAppBar();
158             mShowSearchResults.setValue(true);
159         } else {
160             Log.e(TAG, "reopenSearch called on browse controller");
161         }
162     }
163 
164     @NonNull
getParent()165     private Callbacks getParent() {
166         return mCallbacks;
167     }
168 
getActivity()169     private FragmentActivity getActivity() {
170         return mCallbacks.getActivity();
171     }
172 
173     /**
174      * @return whether the user is at the top of the browsing stack.
175      */
isAtTopStack()176     private boolean isAtTopStack() {
177         if (mIsSearchController) {
178             return mBrowseStack.isEmpty();
179         } else {
180             // The mBrowseStack stack includes the tab...
181             return mBrowseStack.size() <= 1;
182         }
183     }
184 
185     /**
186      * Creates a new instance of this controller meant to browse the root node.
187      * @return a fully initialized {@link BrowseViewController}
188      */
newInstance(Callbacks callbacks, CarPackageManager carPackageManager, ViewGroup container)189     public static BrowseViewController newInstance(Callbacks callbacks,
190             CarPackageManager carPackageManager, ViewGroup container) {
191         boolean isSearchController = false;
192         return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
193     }
194 
195     /**
196      * Creates a new instance of this controller meant to display search results. The root browse
197      * screen will be the search results for the provided query.
198      *
199      * @return a fully initialized {@link BrowseViewController}
200      */
newSearchInstance(Callbacks callbacks, CarPackageManager carPackageManager, ViewGroup container)201     static BrowseViewController newSearchInstance(Callbacks callbacks,
202             CarPackageManager carPackageManager, ViewGroup container) {
203         boolean isSearchController = true;
204         return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
205     }
206 
updateSearchQuery(@ullable String query)207     private void updateSearchQuery(@Nullable String query) {
208         mSearchQuery = query;
209         mMediaBrowserViewModel.search(query);
210     }
211 
212     /**
213      * Clears search state, removes any UI elements from previous results.
214      */
215     @Override
onMediaSourceChanged(@ullable MediaSource mediaSource)216     void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
217         super.onMediaSourceChanged(mediaSource);
218 
219         mBrowseTreeHasChildren = false;
220 
221         if (mIsSearchController) {
222             updateSearchQuery(mViewModel.getSearchQuery());
223             mAppBarView.setSearchQuery(mSearchQuery);
224             mBrowseStack = mViewModel.getSearchStack();
225             mShowSearchResults.setValue(isAtTopStack());
226         } else {
227             mBrowseStack = mViewModel.getBrowseStack();
228             mShowSearchResults.setValue(false);
229             updateTabs((mediaSource != null) ? null : new ArrayList<>());
230         }
231 
232         mBrowseAdapter.submitItems(null, null);
233         stopLoadingIndicator();
234         ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
235         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
236 
237         mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
238 
239         updateAppBar();
240     }
241 
BrowseViewController(Callbacks callbacks, CarPackageManager carPackageManager, ViewGroup container, boolean isSearchController)242     private BrowseViewController(Callbacks callbacks, CarPackageManager carPackageManager,
243             ViewGroup container, boolean isSearchController) {
244         super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse);
245 
246         mCallbacks = callbacks;
247         mIsSearchController = isSearchController;
248 
249         mLoadingIndicatorDelay = mContent.getContext().getResources()
250                 .getInteger(R.integer.progress_indicator_delay);
251 
252         mAppBarView.setListener(mAppBarListener);
253         mBrowseList = mContent.findViewById(R.id.browse_list);
254         mErrorIcon = mContent.findViewById(R.id.error_icon);
255         mMessage = mContent.findViewById(R.id.error_message);
256         mFadeDuration = mContent.getContext().getResources().getInteger(
257                 R.integer.new_album_art_fade_in_duration);
258 
259 
260         FragmentActivity activity = callbacks.getActivity();
261 
262         mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
263 
264         // Browse logic for the root node
265         mRootMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(
266                 mMediaSourceVM, ViewModelProviders.of(activity));
267         mRootMediaBrowserViewModel.getBrowsedMediaItems()
268                 .observe(activity, futureData -> onItemsUpdate(/* forRoot */ true, futureData));
269 
270         mRootMediaBrowserViewModel.supportsSearch().observe(activity,
271                 mAppBarView::setSearchSupported);
272 
273 
274         // Browse logic for current node
275         mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
276                 mIsSearchController ? SEARCH_BROWSER_VIEW_MODEL_KEY : REGULAR_BROWSER_VIEW_MODEL_KEY,
277                 ViewModelProviders.of(activity),
278                 mMediaSourceVM.getConnectedMediaBrowser());
279 
280         mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
281                 activity.getResources().getDimensionPixelSize(R.dimen.grid_item_spacing)));
282 
283         mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext());
284         mBrowseList.setAdapter(mBrowseAdapter);
285         mBrowseAdapter.registerObserver(mBrowseAdapterObserver);
286 
287         mMediaBrowserViewModel.rootBrowsableHint().observe(activity,
288                 mBrowseAdapter::setRootBrowsableViewType);
289         mMediaBrowserViewModel.rootPlayableHint().observe(activity,
290                 mBrowseAdapter::setRootPlayableViewType);
291         LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
292                 mMediaBrowserViewModel.getSearchedMediaItems(),
293                 mMediaBrowserViewModel.getBrowsedMediaItems());
294 
295         mediaItems.observe(activity, futureData -> onItemsUpdate(/* forRoot */ false, futureData));
296 
297         updateAppBar();
298     }
299 
300     private AppBarView.AppBarListener mAppBarListener = new BasicAppBarListener() {
301         @Override
302         public void onTabSelected(MediaItemMetadata item) {
303             if (mAcceptTabSelection) {
304                 showTopItem(item);
305             }
306         }
307 
308         @Override
309         public void onBack() {
310             onBackPressed();
311         }
312 
313         @Override
314         public void onSearchSelection() {
315             if (mIsSearchController) {
316                 reopenSearch();
317             } else {
318                 mCallbacks.changeMode(MediaActivity.Mode.SEARCHING);
319             }
320         }
321 
322         @Override
323         public void onHeightChanged(int height) {
324             onAppBarHeightChanged(height);
325         }
326 
327         @Override
328         public void onSearch(String query) {
329             if (Log.isLoggable(TAG, Log.DEBUG)) {
330                 Log.d(TAG, "onSearch: " + query);
331             }
332             mViewModel.setSearchQuery(query);
333             updateSearchQuery(query);
334         }
335     };
336 
337 
338     private Runnable mLoadingIndicatorRunnable = new Runnable() {
339         @Override
340         public void run() {
341             mMessage.setText(R.string.browser_loading);
342             ViewUtils.showViewAnimated(mMessage, mFadeDuration);
343         }
344     };
345 
onBackPressed()346     boolean onBackPressed() {
347         boolean success = navigateBack();
348         if (!success && (mIsSearchController)) {
349             mCallbacks.changeMode(MediaActivity.Mode.BROWSING);
350             return true;
351         }
352         return success;
353     }
354 
browseTreeHasChildren()355     boolean browseTreeHasChildren() {
356         return mBrowseTreeHasChildren;
357     }
358 
startLoadingIndicator()359     private void startLoadingIndicator() {
360         // Display the indicator after a certain time, to avoid flashing the indicator constantly,
361         // even when performance is acceptable.
362         mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay);
363     }
364 
stopLoadingIndicator()365     private void stopLoadingIndicator() {
366         mHandler.removeCallbacks(mLoadingIndicatorRunnable);
367         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
368     }
369 
navigateInto(@ullable MediaItemMetadata item)370     private void navigateInto(@Nullable MediaItemMetadata item) {
371         if (item != null) {
372             mBrowseStack.push(item);
373             mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
374         } else {
375             mMediaBrowserViewModel.setCurrentBrowseId(null);
376         }
377 
378         mShowSearchResults.setValue(false);
379         updateAppBar();
380     }
381 
382     /**
383      * @return the current item being displayed
384      */
385     @Nullable
getCurrentMediaItem()386     private MediaItemMetadata getCurrentMediaItem() {
387         return mBrowseStack.isEmpty() ? null : mBrowseStack.lastElement();
388     }
389 
390     @Nullable
getCurrentMediaItemId()391     private String getCurrentMediaItemId() {
392         MediaItemMetadata currentItem = getCurrentMediaItem();
393         return currentItem != null ? currentItem.getId() : null;
394     }
395 
onAppBarHeightChanged(int height)396     private void onAppBarHeightChanged(int height) {
397         if (mBrowseList == null) {
398             return;
399         }
400 
401         mBrowseList.setPadding(mBrowseList.getPaddingLeft(), height,
402                 mBrowseList.getPaddingRight(), mBrowseList.getPaddingBottom());
403     }
404 
onPlaybackControlsChanged(boolean visible)405     void onPlaybackControlsChanged(boolean visible) {
406         if (mBrowseList == null) {
407             return;
408         }
409 
410         Resources res = getActivity().getResources();
411         int bottomPadding = visible
412                 ? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding)
413                 : 0;
414         mBrowseList.setPadding(mBrowseList.getPaddingLeft(), mBrowseList.getPaddingTop(),
415                 mBrowseList.getPaddingRight(), bottomPadding);
416 
417         ViewGroup.MarginLayoutParams messageLayout =
418                 (ViewGroup.MarginLayoutParams) mMessage.getLayoutParams();
419         messageLayout.bottomMargin = bottomPadding;
420         mMessage.setLayoutParams(messageLayout);
421     }
422 
hideKeyboard()423     private void hideKeyboard() {
424         InputMethodManager in =
425                 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
426         in.hideSoftInputFromWindow(mContent.getWindowToken(), 0);
427     }
428 
showTopItem(@ullable MediaItemMetadata item)429     private void showTopItem(@Nullable MediaItemMetadata item) {
430         mViewModel.getBrowseStack().clear();
431         navigateInto(item);
432     }
433 
434     /**
435      * Updates the tabs displayed on the app bar, based on the top level items on the browse tree.
436      * If there is at least one browsable item, we show the browse content of that node. If there
437      * are only playable items, then we show those items. If there are not items at all, we show the
438      * empty message. If we receive null, we show the error message.
439      *
440      * @param items top level items, null if the items are still being loaded, or empty list if
441      *              items couldn't be loaded.
442      */
updateTabs(@ullable List<MediaItemMetadata> items)443     private void updateTabs(@Nullable List<MediaItemMetadata> items) {
444         if (Objects.equals(mTopItems, items)) {
445             // When coming back to the app, the live data sends an update even if the list hasn't
446             // changed. Updating the tabs then recreates the browse view, which produces jank
447             // (b/131830876), and also resets the navigation to the top of the first tab...
448             return;
449         }
450         mTopItems = items;
451 
452         if (mTopItems == null || mTopItems.isEmpty()) {
453             mAppBarView.setItems(null);
454             mAppBarView.setActiveItem(null);
455             if (items != null) {
456                 // Only do this when not loading the tabs or we loose the saved one.
457                 showTopItem(null);
458             }
459             updateAppBar();
460             return;
461         }
462 
463         MediaItemMetadata oldTab = mViewModel.getSelectedTab();
464         try {
465             mAcceptTabSelection = false;
466             mAppBarView.setItems(mTopItems.size() == 1 ? null : mTopItems);
467             updateAppBar();
468 
469             if (items.contains(oldTab)) {
470                 mAppBarView.setActiveItem(oldTab);
471             } else {
472                 showTopItem(items.get(0));
473             }
474         }  finally {
475             mAcceptTabSelection = true;
476         }
477     }
478 
updateAppBarTitle()479     private void updateAppBarTitle() {
480         boolean isStacked = !isAtTopStack();
481 
482         final CharSequence title;
483         if (isStacked) {
484             // If not at top level, show the current item as title
485             title = getCurrentMediaItem().getTitle();
486         } else if (mTopItems == null) {
487             // If still loading the tabs, force to show an empty bar.
488             title = "";
489         } else if (mTopItems.size() == 1) {
490             // If we finished loading tabs and there is only one, use that as title.
491             title = mTopItems.get(0).getTitle();
492         } else {
493             // Otherwise (no tabs or more than 1 tabs), show the current media source title.
494             MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
495             title = getAppBarDefaultTitle(mediaSource);
496         }
497 
498         mAppBarView.setTitle(title);
499     }
500 
501     /**
502      * Update elements of the appbar that change depending on where we are in the browse.
503      */
updateAppBar()504     private void updateAppBar() {
505         boolean isStacked = !isAtTopStack();
506         if (Log.isLoggable(TAG, Log.DEBUG)) {
507             Log.d(TAG, "App bar is in stacked state: " + isStacked);
508         }
509         Toolbar.State unstackedState =
510                 mIsSearchController ? Toolbar.State.SEARCH : Toolbar.State.HOME;
511         updateAppBarTitle();
512         mAppBarView.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
513         mAppBarView.showSearchIfSupported(!mIsSearchController || isStacked);
514     }
515 
getErrorMessage(boolean forRoot)516     private String getErrorMessage(boolean forRoot) {
517         if (forRoot) {
518             MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
519             return getActivity().getString(
520                     R.string.cannot_connect_to_app,
521                     mediaSource != null
522                             ? mediaSource.getDisplayName()
523                             : getActivity().getString(
524                                     R.string.unknown_media_provider_name));
525         } else {
526             return getActivity().getString(R.string.unknown_error);
527         }
528     }
529 
530     /**
531      * Filters the items that are valid for the root (tabs) or the current node. Returns null when
532      * the given list is null to preserve its error signal.
533      */
534     @Nullable
filterItems(boolean forRoot, @Nullable List<MediaItemMetadata> items)535     private List<MediaItemMetadata> filterItems(boolean forRoot,
536             @Nullable List<MediaItemMetadata> items) {
537         if (items == null) return null;
538         Predicate<MediaItemMetadata> predicate = forRoot ? MediaItemMetadata::isBrowsable
539                 : item -> (item.isPlayable() || item.isBrowsable());
540         return items.stream().filter(predicate).collect(Collectors.toList());
541     }
542 
onItemsUpdate(boolean forRoot, FutureData<List<MediaItemMetadata>> futureData)543     private void onItemsUpdate(boolean forRoot, FutureData<List<MediaItemMetadata>> futureData) {
544 
545         // Prevent showing loading spinner or any error messages if search is uninitialized
546         if (mIsSearchController && TextUtils.isEmpty(mSearchQuery)) {
547             return;
548         }
549 
550         if (!forRoot && !mBrowseTreeHasChildren && !mIsSearchController) {
551             // Ignore live data ghost values
552             return;
553         }
554 
555         if (futureData.isLoading()) {
556             startLoadingIndicator();
557             ViewUtils.hideViewAnimated(mErrorIcon, 0);
558             ViewUtils.hideViewAnimated(mMessage, 0);
559             // TODO(b/139759881) build a jank-free animation of the transition.
560             mBrowseList.setAlpha(0f);
561             mBrowseAdapter.submitItems(null, null);
562 
563             if (forRoot) {
564                 if (Log.isLoggable(TAG, Log.INFO)) {
565                     Log.i(TAG, "Loading browse tree...");
566                 }
567                 mBrowseTreeHasChildren = false;
568                 updateTabs(null);
569             }
570             return;
571         }
572 
573         stopLoadingIndicator();
574 
575         List<MediaItemMetadata> items = filterItems(forRoot, futureData.getData());
576         if (forRoot) {
577             boolean browseTreeHasChildren = items != null && !items.isEmpty();
578             if (Log.isLoggable(TAG, Log.INFO)) {
579                 Log.i(TAG, "Browse tree loaded, status (has children or not) changed: "
580                         + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren);
581             }
582             mBrowseTreeHasChildren = browseTreeHasChildren;
583             mCallbacks.onRootLoaded();
584             updateTabs(items != null ? items : new ArrayList<>());
585         } else {
586             mBrowseAdapter.submitItems(getCurrentMediaItem(), items);
587         }
588 
589         int duration = forRoot ? 0 : mFadeDuration;
590         if (items == null) {
591             mMessage.setText(getErrorMessage(forRoot));
592             ViewUtils.hideViewAnimated(mBrowseList, duration);
593             ViewUtils.showViewAnimated(mMessage, duration);
594             ViewUtils.showViewAnimated(mErrorIcon, duration);
595         } else if (items.isEmpty()) {
596             mMessage.setText(R.string.nothing_to_play);
597             ViewUtils.hideViewAnimated(mBrowseList, duration);
598             ViewUtils.hideViewAnimated(mErrorIcon, duration);
599             ViewUtils.showViewAnimated(mMessage, duration);
600         } else if (!forRoot) {
601             ViewUtils.showViewAnimated(mBrowseList, duration);
602             ViewUtils.hideViewAnimated(mErrorIcon, duration);
603             ViewUtils.hideViewAnimated(mMessage, duration);
604         }
605     }
606 }
607