• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.apps.common.FragmentUtils.checkParent;
20 import static com.android.car.apps.common.FragmentUtils.requireParent;
21 import static com.android.car.arch.common.LiveDataFunctions.ifThenElse;
22 
23 import android.annotation.NonNull;
24 import android.annotation.Nullable;
25 import android.content.Context;
26 import android.os.Bundle;
27 import android.os.Handler;
28 import android.text.TextUtils;
29 import android.view.LayoutInflater;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.view.inputmethod.InputMethodManager;
33 import android.widget.ImageView;
34 import android.widget.TextView;
35 
36 import androidx.fragment.app.Fragment;
37 import androidx.lifecycle.LiveData;
38 import androidx.lifecycle.MutableLiveData;
39 import androidx.lifecycle.ViewModelProviders;
40 import androidx.recyclerview.widget.GridLayoutManager;
41 import androidx.recyclerview.widget.RecyclerView;
42 
43 import com.android.car.apps.common.util.ViewUtils;
44 import com.android.car.arch.common.FutureData;
45 import com.android.car.media.browse.BrowseAdapter;
46 import com.android.car.media.common.GridSpacingItemDecoration;
47 import com.android.car.media.common.MediaItemMetadata;
48 import com.android.car.media.common.browse.MediaBrowserViewModel;
49 import com.android.car.media.common.source.MediaSourceViewModel;
50 
51 import java.util.ArrayList;
52 import java.util.List;
53 import java.util.Stack;
54 
55 /**
56  * A {@link Fragment} that implements the content forward browsing experience.
57  *
58  * This can be used to display either search or browse results at the root level. Deeper levels will
59  * be handled the same way between search and browse, using a backstack to return to the root.
60  */
61 public class BrowseFragment extends Fragment {
62     private static final String TAG = "BrowseFragment";
63     private static final String TOP_MEDIA_ITEM_KEY = "top_media_item";
64     private static final String SEARCH_KEY = "search_config";
65     private static final String BROWSE_STACK_KEY = "browse_stack";
66 
67     private RecyclerView mBrowseList;
68     private ImageView mErrorIcon;
69     private TextView mMessage;
70     private BrowseAdapter mBrowseAdapter;
71     private MediaItemMetadata mTopMediaItem;
72     private String mSearchQuery;
73     private int mFadeDuration;
74     private int mLoadingIndicatorDelay;
75     private boolean mIsSearchFragment;
76     private boolean mPlaybackControlsVisible = false;
77     // todo(b/130760002): Create new browse fragments at deeper levels.
78     private MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
79     private Handler mHandler = new Handler();
80     private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
81     private MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
82     private BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
83 
84         @Override
85         protected void onPlayableItemClicked(MediaItemMetadata item) {
86             hideKeyboard();
87             getParent().onPlayableItemClicked(item);
88         }
89 
90         @Override
91         protected void onBrowsableItemClicked(MediaItemMetadata item) {
92             navigateInto(item);
93         }
94     };
95 
96     /**
97      * Fragment callbacks (implemented by the hosting Activity)
98      */
99     public interface Callbacks {
100         /**
101          * Method invoked when the back stack changes (for example, when the user moves up or down
102          * the media tree)
103          */
onBackStackChanged()104         void onBackStackChanged();
105 
106         /**
107          * Method invoked when the user clicks on a playable item
108          *
109          * @param item item to be played.
110          */
onPlayableItemClicked(MediaItemMetadata item)111         void onPlayableItemClicked(MediaItemMetadata item);
112     }
113 
114     /**
115      * Moves the user one level up in the browse tree. Returns whether that was possible.
116      */
navigateBack()117     boolean navigateBack() {
118         boolean result = false;
119         if (!mBrowseStack.empty()) {
120             mBrowseStack.pop();
121             mMediaBrowserViewModel.search(mSearchQuery);
122             mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
123             getParent().onBackStackChanged();
124             adjustBrowseTopPadding();
125             result = true;
126         }
127         if (mBrowseStack.isEmpty()) {
128             mShowSearchResults.setValue(mIsSearchFragment);
129         }
130         return result;
131     }
132 
133     @NonNull
getParent()134     private Callbacks getParent() {
135         return requireParent(this, Callbacks.class);
136     }
137 
138     /**
139      * @return whether the user is at the top of the browsing stack.
140      */
isAtTopStack()141     public boolean isAtTopStack() {
142         return mBrowseStack.isEmpty();
143     }
144 
145     /**
146      * Creates a new instance of this fragment. The root browse id will be the one provided to this
147      * method.
148      *
149      * @param item media tree node to display on this fragment.
150      * @return a fully initialized {@link BrowseFragment}
151      */
newInstance(MediaItemMetadata item)152     public static BrowseFragment newInstance(MediaItemMetadata item) {
153         BrowseFragment fragment = new BrowseFragment();
154         Bundle args = new Bundle();
155         args.putParcelable(TOP_MEDIA_ITEM_KEY, item);
156         fragment.setArguments(args);
157         return fragment;
158     }
159 
160     /**
161      * Creates a new instance of this fragment, meant to display search results. The root browse
162      * screen will be the search results for the provided query.
163      *
164      * @return a fully initialized {@link BrowseFragment}
165      */
newSearchInstance()166     public static BrowseFragment newSearchInstance() {
167         BrowseFragment fragment = new BrowseFragment();
168         Bundle args = new Bundle();
169         args.putBoolean(SEARCH_KEY, true);
170         fragment.setArguments(args);
171         return fragment;
172     }
173 
updateSearchQuery(@ullable String query)174     public void updateSearchQuery(@Nullable String query) {
175         mSearchQuery = query;
176         mMediaBrowserViewModel.search(query);
177     }
178 
179     /**
180      * Clears search state from this fragment, removes any UI elements from previous results.
181      */
resetSearchState()182     public void resetSearchState() {
183         updateSearchQuery(null);
184         mBrowseAdapter.submitItems(null, null);
185         stopLoadingIndicator();
186         ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
187         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
188     }
189 
190     @Override
onCreate(@ullable Bundle savedInstanceState)191     public void onCreate(@Nullable Bundle savedInstanceState) {
192         super.onCreate(savedInstanceState);
193         Bundle arguments = getArguments();
194         if (arguments != null) {
195             mTopMediaItem = arguments.getParcelable(TOP_MEDIA_ITEM_KEY);
196             mIsSearchFragment = arguments.getBoolean(SEARCH_KEY, false);
197             mShowSearchResults.setValue(mIsSearchFragment);
198         }
199         if (savedInstanceState != null) {
200             List<MediaItemMetadata> savedStack =
201                     savedInstanceState.getParcelableArrayList(BROWSE_STACK_KEY);
202             mBrowseStack.clear();
203             if (savedStack != null) {
204                 mBrowseStack.addAll(savedStack);
205             }
206         }
207 
208         // Get the MediaBrowserViewModel tied to the lifecycle of this fragment, but using the
209         // MediaSourceViewModel of the activity. This means the media source is consistent across
210         // all fragments, but the fragment contents themselves will vary
211         // (e.g. between different browse tabs, search)
212         mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
213                 ViewModelProviders.of(this),
214                 MediaSourceViewModel.get(
215                         requireActivity().getApplication()).getConnectedMediaBrowser());
216 
217         MediaActivity.ViewModel viewModel = ViewModelProviders.of(requireActivity()).get(
218                 MediaActivity.ViewModel.class);
219         viewModel.getMiniControlsVisible().observe(this, (visible) -> {
220             mPlaybackControlsVisible = visible;
221             adjustBrowseTopPadding();
222         });
223 
224     }
225 
226     @Override
onCreateView(@onNull LayoutInflater inflater, final ViewGroup container, Bundle savedInstanceState)227     public View onCreateView(@NonNull LayoutInflater inflater, final ViewGroup container,
228             Bundle savedInstanceState) {
229         int viewId = mIsSearchFragment ? R.layout.fragment_search : R.layout.fragment_browse;
230         View view = inflater.inflate(viewId, container, false);
231         mLoadingIndicatorDelay = view.getContext().getResources()
232                 .getInteger(R.integer.progress_indicator_delay);
233         mBrowseList = view.findViewById(R.id.browse_list);
234         mErrorIcon = view.findViewById(R.id.error_icon);
235         mMessage = view.findViewById(R.id.error_message);
236         mFadeDuration = view.getContext().getResources().getInteger(
237                 R.integer.new_album_art_fade_in_duration);
238         int numColumns = view.getContext().getResources().getInteger(R.integer.num_browse_columns);
239         GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), numColumns);
240 
241         mBrowseList.setLayoutManager(gridLayoutManager);
242         mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
243                 getResources().getDimensionPixelSize(R.dimen.grid_item_spacing),
244                 getResources().getDimensionPixelSize(R.dimen.grid_item_margin_x),
245                 getResources().getDimensionPixelSize(R.dimen.grid_item_margin_x)
246         ));
247 
248         mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext());
249         mBrowseList.setAdapter(mBrowseAdapter);
250         mBrowseAdapter.registerObserver(mBrowseAdapterObserver);
251 
252         if (savedInstanceState == null) {
253             mMediaBrowserViewModel.search(mSearchQuery);
254             mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
255         }
256         mMediaBrowserViewModel.rootBrowsableHint().observe(this, hint ->
257                 mBrowseAdapter.setRootBrowsableViewType(hint));
258         mMediaBrowserViewModel.rootPlayableHint().observe(this, hint ->
259                 mBrowseAdapter.setRootPlayableViewType(hint));
260         LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
261                 mMediaBrowserViewModel.getSearchedMediaItems(),
262                 mMediaBrowserViewModel.getBrowsedMediaItems());
263 
264         mediaItems.observe(getViewLifecycleOwner(), futureData ->
265         {
266             // Prevent showing loading spinner or any error messages if search is uninitialized
267             if (mIsSearchFragment && TextUtils.isEmpty(mSearchQuery)) {
268                 return;
269             }
270             boolean isLoading = futureData.isLoading();
271             if (isLoading) {
272                 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
273                 startLoadingIndicator();
274                 return;
275             }
276             stopLoadingIndicator();
277             List<MediaItemMetadata> items = futureData.getData();
278             mBrowseAdapter.submitItems(getCurrentMediaItem(), items);
279             if (items == null) {
280                 mMessage.setText(R.string.unknown_error);
281                 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
282                 ViewUtils.showViewAnimated(mMessage, mFadeDuration);
283                 ViewUtils.showViewAnimated(mErrorIcon, mFadeDuration);
284             } else if (items.isEmpty()) {
285                 mMessage.setText(R.string.nothing_to_play);
286                 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
287                 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
288                 ViewUtils.showViewAnimated(mMessage, mFadeDuration);
289             } else {
290                 ViewUtils.showViewAnimated(mBrowseList, mFadeDuration);
291                 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
292                 ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
293             }
294         });
295         return view;
296     }
297 
298     @Override
onAttach(Context context)299     public void onAttach(Context context) {
300         super.onAttach(context);
301         checkParent(this, Callbacks.class);
302     }
303 
304     private Runnable mLoadingIndicatorRunnable = new Runnable() {
305         @Override
306         public void run() {
307             mMessage.setText(R.string.browser_loading);
308             ViewUtils.showViewAnimated(mMessage, mFadeDuration);
309         }
310     };
311 
startLoadingIndicator()312     private void startLoadingIndicator() {
313         // Display the indicator after a certain time, to avoid flashing the indicator constantly,
314         // even when performance is acceptable.
315         mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay);
316     }
317 
stopLoadingIndicator()318     private void stopLoadingIndicator() {
319         mHandler.removeCallbacks(mLoadingIndicatorRunnable);
320         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
321     }
322 
323     @Override
onSaveInstanceState(@onNull Bundle outState)324     public void onSaveInstanceState(@NonNull Bundle outState) {
325         super.onSaveInstanceState(outState);
326         ArrayList<MediaItemMetadata> stack = new ArrayList<>(mBrowseStack);
327         outState.putParcelableArrayList(BROWSE_STACK_KEY, stack);
328     }
329 
navigateInto(MediaItemMetadata item)330     private void navigateInto(MediaItemMetadata item) {
331         hideKeyboard();
332         mBrowseStack.push(item);
333         mShowSearchResults.setValue(false);
334         mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
335         getParent().onBackStackChanged();
336         adjustBrowseTopPadding();
337     }
338 
339     /**
340      * @return the current item being displayed
341      */
342     @Nullable
getCurrentMediaItem()343     MediaItemMetadata getCurrentMediaItem() {
344         if (mBrowseStack.isEmpty()) {
345             return mTopMediaItem;
346         } else {
347             return mBrowseStack.lastElement();
348         }
349     }
350 
351     @Nullable
getCurrentMediaItemId()352     private String getCurrentMediaItemId() {
353         MediaItemMetadata currentItem = getCurrentMediaItem();
354         return currentItem != null ? currentItem.getId() : null;
355     }
356 
adjustBrowseTopPadding()357     private void adjustBrowseTopPadding() {
358         if(mBrowseList == null) {
359             return;
360         }
361 
362         int topPadding = isAtTopStack()
363                 ? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_top_padding)
364                 : getResources().getDimensionPixelOffset(
365                         R.dimen.browse_fragment_top_padding_stacked);
366         int bottomPadding = mPlaybackControlsVisible
367                 ? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding)
368                 : 0;
369 
370         mBrowseList.setPadding(mBrowseList.getPaddingLeft(), topPadding,
371                 mBrowseList.getPaddingRight(), bottomPadding);
372     }
373 
hideKeyboard()374     private void hideKeyboard() {
375         InputMethodManager in =
376                 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
377         in.hideSoftInputFromWindow(getView().getWindowToken(), 0);
378     }
379 }
380