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.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.content.Context;
24 import android.content.res.Resources;
25 import android.os.Handler;
26 import android.text.TextUtils;
27 import android.util.Log;
28 import android.view.LayoutInflater;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.view.inputmethod.InputMethodManager;
32 import android.widget.ImageView;
33 import android.widget.TextView;
34 
35 import androidx.fragment.app.FragmentActivity;
36 import androidx.lifecycle.LiveData;
37 import androidx.lifecycle.MutableLiveData;
38 import androidx.lifecycle.ViewModelProviders;
39 import androidx.recyclerview.widget.RecyclerView;
40 
41 import com.android.car.apps.common.util.ViewUtils;
42 import com.android.car.arch.common.FutureData;
43 import com.android.car.media.browse.BrowseAdapter;
44 import com.android.car.media.common.GridSpacingItemDecoration;
45 import com.android.car.media.common.MediaItemMetadata;
46 import com.android.car.media.common.browse.MediaBrowserViewModel;
47 import com.android.car.media.common.source.MediaSource;
48 import com.android.car.media.common.source.MediaSourceViewModel;
49 
50 import java.util.List;
51 import java.util.Stack;
52 import java.util.stream.Collectors;
53 
54 /**
55  * A view "controller" that implements the content forward browsing experience.
56  *
57  * This can be used to display either search or browse results at the root level. Deeper levels will
58  * be handled the same way between search and browse, using a back stack to return to the root.
59  */
60 public class BrowseFragment {
61     private static final String TAG = "BrowseFragment";
62 
63     private static final String REGULAR_BROWSER_VIEW_MODEL_KEY
64             = "com.android.car.media.regular_browser_view_model";
65     private static final String SEARCH_BROWSER_VIEW_MODEL_KEY
66             = "com.android.car.media.search_browser_view_model";
67 
68     private final Callbacks mCallbacks;
69     private final View mFragmentContent;
70 
71     private RecyclerView mBrowseList;
72     private ViewGroup mBrowseState;
73     private ImageView mErrorIcon;
74     private TextView mMessage;
75     private BrowseAdapter mBrowseAdapter;
76     private String mSearchQuery;
77     private int mFadeDuration;
78     private int mLoadingIndicatorDelay;
79     private boolean mIsSearchFragment;
80     // todo(b/130760002): Create new browse fragments at deeper levels.
81     private MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
82     private Handler mHandler = new Handler();
83     private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
84     private MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
85     private BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
86 
87         @Override
88         protected void onPlayableItemClicked(MediaItemMetadata item) {
89             hideKeyboard();
90             getParent().onPlayableItemClicked(item);
91         }
92 
93         @Override
94         protected void onBrowsableItemClicked(MediaItemMetadata item) {
95             hideKeyboard();
96             navigateInto(item);
97         }
98     };
99 
100     /**
101      * Fragment callbacks (implemented by the hosting Activity)
102      */
103     public interface Callbacks {
104         /**
105          * Method invoked when the back stack changes (for example, when the user moves up or down
106          * the media tree)
107          */
onBackStackChanged()108         void onBackStackChanged();
109 
110         /**
111          * Method invoked when the user clicks on a playable item
112          *
113          * @param item item to be played.
114          */
onPlayableItemClicked(MediaItemMetadata item)115         void onPlayableItemClicked(MediaItemMetadata item);
116 
getActivity()117         FragmentActivity getActivity();
118     }
119 
120     /**
121      * Moves the user one level up in the browse tree. Returns whether that was possible.
122      */
navigateBack()123     boolean navigateBack() {
124         boolean result = false;
125         if (!isAtTopStack()) {
126             mBrowseStack.pop();
127             mMediaBrowserViewModel.search(mSearchQuery);
128             mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
129             getParent().onBackStackChanged();
130             result = true;
131         }
132         if (isAtTopStack()) {
133             mShowSearchResults.setValue(mIsSearchFragment);
134         }
135         return result;
136     }
137 
reopenSearch()138     void reopenSearch() {
139         if (mIsSearchFragment) {
140             mBrowseStack.clear();
141             getParent().onBackStackChanged();
142             mShowSearchResults.setValue(true);
143         } else {
144             Log.e(TAG, "reopenSearch called on browse fragment");
145         }
146     }
147 
148     @NonNull
getParent()149     private Callbacks getParent() {
150         return mCallbacks;
151     }
152 
getActivity()153     private FragmentActivity getActivity() {
154         return mCallbacks.getActivity();
155     }
156 
157     /**
158      * @return whether the user is at the top of the browsing stack.
159      */
isAtTopStack()160     boolean isAtTopStack() {
161         if (mIsSearchFragment) {
162             return mBrowseStack.isEmpty();
163         } else {
164             // The mBrowseStack stack includes the tab...
165             return mBrowseStack.size() <= 1;
166         }
167     }
168 
169     /**
170      * Creates a new instance of this fragment. The root browse id will be the one provided to this
171      * method.
172      * @return a fully initialized {@link BrowseFragment}
173      */
newInstance(Callbacks callbacks, ViewGroup container)174     public static BrowseFragment newInstance(Callbacks callbacks, ViewGroup container) {
175         boolean isSearchFragment = false;
176         return new BrowseFragment(callbacks, container, isSearchFragment);
177     }
178 
179     /**
180      * Creates a new instance of this fragment, meant to display search results. The root browse
181      * screen will be the search results for the provided query.
182      *
183      * @return a fully initialized {@link BrowseFragment}
184      */
newSearchInstance(Callbacks callbacks, ViewGroup container)185     static BrowseFragment newSearchInstance(Callbacks callbacks, ViewGroup container) {
186         boolean isSearchFragment = true;
187         return new BrowseFragment(callbacks, container, isSearchFragment);
188     }
189 
updateSearchQuery(@ullable String query)190     void updateSearchQuery(@Nullable String query) {
191         mSearchQuery = query;
192         mMediaBrowserViewModel.search(query);
193     }
194 
195     /**
196      * Clears search state from this fragment, removes any UI elements from previous results.
197      */
resetState()198     void resetState() {
199         MediaActivity.ViewModel viewModel = ViewModelProviders.of(mCallbacks.getActivity()).get(
200                 MediaActivity.ViewModel.class);
201 
202         if (mIsSearchFragment) {
203             updateSearchQuery(viewModel.getSearchQuery());
204             mShowSearchResults.setValue(false);
205             mBrowseStack = viewModel.getSearchStack();
206             mShowSearchResults.setValue(isAtTopStack());
207         } else {
208             mBrowseStack = viewModel.getBrowseStack();
209             mShowSearchResults.setValue(false);
210         }
211 
212         mBrowseAdapter.submitItems(null, null);
213         stopLoadingIndicator();
214         ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
215         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
216 
217         mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
218     }
219 
BrowseFragment(Callbacks callbacks, ViewGroup container, boolean isSearchFragment)220     private BrowseFragment(Callbacks callbacks, ViewGroup container, boolean isSearchFragment) {
221         mCallbacks = callbacks;
222 
223         FragmentActivity activity = callbacks.getActivity();
224 
225         mIsSearchFragment = isSearchFragment;
226         mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
227                 mIsSearchFragment ? SEARCH_BROWSER_VIEW_MODEL_KEY : REGULAR_BROWSER_VIEW_MODEL_KEY,
228                 ViewModelProviders.of(activity),
229                 MediaSourceViewModel.get(activity.getApplication()).getConnectedMediaBrowser());
230 
231         int viewId = mIsSearchFragment ? R.layout.fragment_search : R.layout.fragment_browse;
232         mFragmentContent =
233                 LayoutInflater.from(container.getContext()).inflate(viewId, container, false);
234         mLoadingIndicatorDelay = mFragmentContent.getContext().getResources()
235                 .getInteger(R.integer.progress_indicator_delay);
236         mBrowseList = mFragmentContent.findViewById(R.id.browse_list);
237         mBrowseState = mFragmentContent.findViewById(R.id.browse_state);
238         mErrorIcon = mFragmentContent.findViewById(R.id.error_icon);
239         mMessage = mFragmentContent.findViewById(R.id.error_message);
240         mFadeDuration = mFragmentContent.getContext().getResources().getInteger(
241                 R.integer.new_album_art_fade_in_duration);
242 
243         mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
244                 activity.getResources().getDimensionPixelSize(R.dimen.grid_item_spacing)));
245 
246         mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext());
247         mBrowseList.setAdapter(mBrowseAdapter);
248         mBrowseAdapter.registerObserver(mBrowseAdapterObserver);
249 
250         mMediaBrowserViewModel.rootBrowsableHint().observe(activity, hint ->
251                 mBrowseAdapter.setRootBrowsableViewType(hint));
252         mMediaBrowserViewModel.rootPlayableHint().observe(activity, hint ->
253                 mBrowseAdapter.setRootPlayableViewType(hint));
254         LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
255                 mMediaBrowserViewModel.getSearchedMediaItems(),
256                 mMediaBrowserViewModel.getBrowsedMediaItems());
257 
258         // TODO(b/145688665) merge with #update
259         mediaItems.observe(activity, futureData ->
260         {
261             // Prevent showing loading spinner or any error messages if search is uninitialized
262             if (mIsSearchFragment && TextUtils.isEmpty(mSearchQuery)) {
263                 return;
264             }
265             boolean isLoading = futureData.isLoading();
266             if (isLoading) {
267                 // TODO(b/139759881) build a jank-free animation of the transition.
268                 mBrowseList.setAlpha(0f);
269                 startLoadingIndicator();
270                 mBrowseAdapter.submitItems(null, null);
271                 return;
272             }
273             stopLoadingIndicator();
274             List<MediaItemMetadata> items = futureData.getData();
275             if (items != null) {
276                 items = items.stream().filter(item ->
277                         (item.isPlayable() || item.isBrowsable())).collect(Collectors.toList());
278             }
279             mBrowseAdapter.submitItems(getCurrentMediaItem(), items);
280             if (items == null) {
281                 mMessage.setText(R.string.unknown_error);
282                 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
283                 ViewUtils.showViewAnimated(mMessage, mFadeDuration);
284                 ViewUtils.showViewAnimated(mErrorIcon, mFadeDuration);
285             } else if (items.isEmpty()) {
286                 mMessage.setText(R.string.nothing_to_play);
287                 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
288                 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
289                 ViewUtils.showViewAnimated(mMessage, mFadeDuration);
290             } else {
291                 ViewUtils.showViewAnimated(mBrowseList, mFadeDuration);
292                 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
293                 ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
294             }
295         });
296 
297         container.addView(mFragmentContent);
298     }
299 
300     private Runnable mLoadingIndicatorRunnable = new Runnable() {
301         @Override
302         public void run() {
303             mMessage.setText(R.string.browser_loading);
304             ViewUtils.showViewAnimated(mMessage, mFadeDuration);
305         }
306     };
307 
startLoadingIndicator()308     private void startLoadingIndicator() {
309         // Display the indicator after a certain time, to avoid flashing the indicator constantly,
310         // even when performance is acceptable.
311         mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay);
312     }
313 
stopLoadingIndicator()314     private void stopLoadingIndicator() {
315         mHandler.removeCallbacks(mLoadingIndicatorRunnable);
316         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
317     }
318 
navigateInto(@ullable MediaItemMetadata item)319     void navigateInto(@Nullable MediaItemMetadata item) {
320         if (item != null) {
321             mBrowseStack.push(item);
322             mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
323         } else {
324             mMediaBrowserViewModel.setCurrentBrowseId(null);
325         }
326 
327         mShowSearchResults.setValue(false);
328         getParent().onBackStackChanged();
329     }
330 
331     /**
332      * @return the current item being displayed
333      */
334     @Nullable
getCurrentMediaItem()335     MediaItemMetadata getCurrentMediaItem() {
336         return mBrowseStack.isEmpty() ? null : mBrowseStack.lastElement();
337     }
338 
339     @Nullable
getCurrentMediaItemId()340     private String getCurrentMediaItemId() {
341         MediaItemMetadata currentItem = getCurrentMediaItem();
342         return currentItem != null ? currentItem.getId() : null;
343     }
344 
onAppBarHeightChanged(int height)345     public void onAppBarHeightChanged(int height) {
346         if (mBrowseList == null) {
347             return;
348         }
349 
350         mBrowseList.setPadding(mBrowseList.getPaddingLeft(), height,
351                 mBrowseList.getPaddingRight(), mBrowseList.getPaddingBottom());
352     }
353 
onPlaybackControlsChanged(boolean visible, int browseStateTopMargin, int browseStateBottomMargin)354     void onPlaybackControlsChanged(boolean visible, int browseStateTopMargin,
355             int browseStateBottomMargin) {
356         ViewGroup.MarginLayoutParams params =
357                 (ViewGroup.MarginLayoutParams) mBrowseState.getLayoutParams();
358         params.topMargin = browseStateTopMargin;
359         params.bottomMargin = browseStateBottomMargin;
360 
361         if (mBrowseList == null) {
362             return;
363         }
364 
365         Resources res = getActivity().getResources();
366         int bottomPadding = visible
367                 ? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding)
368                 : 0;
369         mBrowseList.setPadding(mBrowseList.getPaddingLeft(), mBrowseList.getPaddingTop(),
370                 mBrowseList.getPaddingRight(), bottomPadding);
371     }
372 
hideKeyboard()373     private void hideKeyboard() {
374         InputMethodManager in =
375                 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
376         in.hideSoftInputFromWindow(mFragmentContent.getWindowToken(), 0);
377     }
378 
379     /**
380      * Updates the state of this fragment.
381      */
setRootState(@onNull MediaBrowserViewModel.BrowseState state, @Nullable MediaSource mediaSource)382     void setRootState(@NonNull MediaBrowserViewModel.BrowseState state,
383             @Nullable MediaSource mediaSource) {
384         mHandler.removeCallbacks(mLoadingIndicatorRunnable);
385         update(state, mediaSource);
386     }
387 
update(@onNull MediaBrowserViewModel.BrowseState state, @Nullable MediaSource mediaSource)388     private void update(@NonNull MediaBrowserViewModel.BrowseState state,
389             @Nullable MediaSource mediaSource) {
390         switch (state) {
391             case LOADING:
392                 // Display the indicator after a certain time, to avoid flashing the indicator
393                 // constantly, even when performance is acceptable.
394                 startLoadingIndicator();
395                 ViewUtils.hideViewAnimated(mErrorIcon, 0);
396                 ViewUtils.hideViewAnimated(mMessage, 0);
397                 break;
398             case ERROR:
399                 ViewUtils.showViewAnimated(mErrorIcon, 0);
400                 ViewUtils.showViewAnimated(mMessage, 0);
401                 mMessage.setText(getActivity().getString(
402                         R.string.cannot_connect_to_app,
403                         mediaSource != null
404                                 ? mediaSource.getDisplayName()
405                                 : getActivity().getString(
406                                         R.string.unknown_media_provider_name)));
407                 break;
408             case EMPTY:
409                 ViewUtils.hideViewAnimated(mErrorIcon, 0);
410                 ViewUtils.showViewAnimated(mMessage, 0);
411                 mMessage.setText(getActivity().getString(R.string.nothing_to_play));
412                 break;
413             case LOADED:
414                 Log.d(TAG, "Updated with LOADED state, ignoring.");
415                 // Do nothing.
416                 break;
417             default:
418                 // Fail fast on any other state.
419                 throw new IllegalStateException("Invalid state for this fragment: " + state);
420         }
421     }
422 }
423