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.util.ViewUtils.removeFromParent;
20 
21 import android.content.res.Resources;
22 import android.os.Handler;
23 import android.util.Log;
24 import android.view.LayoutInflater;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.widget.ImageView;
28 import android.widget.TextView;
29 
30 import androidx.annotation.NonNull;
31 import androidx.annotation.Nullable;
32 import androidx.fragment.app.FragmentActivity;
33 import androidx.lifecycle.Observer;
34 import androidx.lifecycle.ViewModelProviders;
35 import androidx.recyclerview.widget.RecyclerView;
36 
37 import com.android.car.apps.common.util.FutureData;
38 import com.android.car.apps.common.util.ViewUtils;
39 import com.android.car.media.browse.BrowseAdapter;
40 import com.android.car.media.browse.LimitedBrowseAdapter;
41 import com.android.car.media.common.MediaItemMetadata;
42 import com.android.car.media.common.browse.MediaBrowserViewModelImpl;
43 import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData;
44 import com.android.car.media.common.source.MediaSource;
45 import com.android.car.ui.FocusArea;
46 import com.android.car.ui.baselayout.Insets;
47 import com.android.car.ui.recyclerview.CarUiRecyclerView;
48 import com.android.car.uxr.LifeCycleObserverUxrContentLimiter;
49 import com.android.car.uxr.UxrContentLimiterImpl;
50 
51 import java.util.Collection;
52 import java.util.List;
53 
54 /**
55  * A view controller that displays the media item children of a {@link MediaItemMetadata}.
56  * The controller manages a recycler view where the items can be displayed as a list or a grid, as
57  * well as an error icon and a message used to indicate loading and errors.
58  * The content view is initialized with 0 alpha and needs to be animated or set to to full opacity
59  * to become visible.
60  */
61 public class BrowseViewController {
62     private static final String TAG = "BrowseViewController";
63 
64     private final Callbacks mCallbacks;
65     private final FocusArea mFocusArea;
66     private final MediaItemMetadata mParentItem;
67     private final MediaItemsLiveData mMediaItems;
68     private final boolean mDisplayMediaItems;
69     private final LifeCycleObserverUxrContentLimiter mUxrContentLimiter;
70     private final View mContent;
71     private final CarUiRecyclerView mBrowseList;
72     private final ImageView mErrorIcon;
73     private final TextView mMessage;
74     private final LimitedBrowseAdapter mLimitedBrowseAdapter;
75 
76     private final int mFadeDuration;
77     private final int mLoadingIndicatorDelay;
78 
79     private final boolean mSetFocusAreaHighlightBottom;
80 
81     private final Handler mHandler = new Handler();
82 
83     private final MediaActivity.ViewModel mViewModel;
84 
85     private final BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
86 
87         @Override
88         protected void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
89             mCallbacks.onPlayableItemClicked(item);
90         }
91 
92         @Override
93         protected void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
94             mCallbacks.onBrowsableItemClicked(item);
95         }
96     };
97 
98     /**
99      * The bottom padding of the FocusArea highlight.
100      */
101     private int mFocusAreaHighlightBottomPadding;
102 
103     /**
104      * Callbacks (implemented by the host)
105      */
106     public interface Callbacks {
107         /**
108          * Method invoked when the user clicks on a playable item
109          *
110          * @param item item to be played.
111          */
onPlayableItemClicked(@onNull MediaItemMetadata item)112         void onPlayableItemClicked(@NonNull MediaItemMetadata item);
113 
114         /** Invoked when the user clicks on a browsable item. */
onBrowsableItemClicked(@onNull MediaItemMetadata item)115         void onBrowsableItemClicked(@NonNull MediaItemMetadata item);
116 
117         /** Invoked when child nodes have been removed from this controller. */
onChildrenNodesRemoved(@onNull BrowseViewController controller, @NonNull Collection<MediaItemMetadata> removedNodes)118         void onChildrenNodesRemoved(@NonNull BrowseViewController controller,
119                 @NonNull Collection<MediaItemMetadata> removedNodes);
120 
getActivity()121         FragmentActivity getActivity();
122     }
123 
getActivity()124     private FragmentActivity getActivity() {
125         return mCallbacks.getActivity();
126     }
127 
128     /**
129      * Creates a controller to display the children of the given parent {@link MediaItemMetadata}.
130      * This parent node can have been obtained from the browse tree, or from browsing the search
131      * results.
132      */
newBrowseController(Callbacks callbacks, ViewGroup container, @NonNull MediaItemMetadata parentItem, MediaItemsLiveData mediaItems, int rootBrowsableHint, int rootPlayableHint)133     static BrowseViewController newBrowseController(Callbacks callbacks, ViewGroup container,
134             @NonNull MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
135             int rootBrowsableHint, int rootPlayableHint) {
136         return new BrowseViewController(callbacks, container, parentItem, mediaItems,
137                 rootBrowsableHint, rootPlayableHint, true);
138     }
139 
140     /** Creates a controller to display the top results of a search query (in a list). */
newSearchResultsController(Callbacks callbacks, ViewGroup container, MediaItemsLiveData mediaItems)141     static BrowseViewController newSearchResultsController(Callbacks callbacks, ViewGroup container,
142             MediaItemsLiveData mediaItems) {
143         return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, true);
144     }
145 
146     /**
147      * Creates a controller to "display" the children of the root: the children are actually hidden
148      * since they are shown as tabs, and the controller is only used to display loading and error
149      * messages.
150      */
newRootController(Callbacks callbacks, ViewGroup container, MediaItemsLiveData mediaItems)151     static BrowseViewController newRootController(Callbacks callbacks, ViewGroup container,
152             MediaItemsLiveData mediaItems) {
153         return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, false);
154     }
155 
156 
BrowseViewController(Callbacks callbacks, ViewGroup container, @Nullable MediaItemMetadata parentItem, MediaItemsLiveData mediaItems, int rootBrowsableHint, int rootPlayableHint, boolean displayMediaItems)157     private BrowseViewController(Callbacks callbacks, ViewGroup container,
158             @Nullable MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
159             int rootBrowsableHint, int rootPlayableHint, boolean displayMediaItems) {
160         mCallbacks = callbacks;
161         mParentItem = parentItem;
162         mMediaItems = mediaItems;
163         mDisplayMediaItems = displayMediaItems;
164 
165         LayoutInflater inflater = LayoutInflater.from(container.getContext());
166         mContent = inflater.inflate(R.layout.browse_node, container, false);
167         mContent.setAlpha(0f);
168         container.addView(mContent);
169 
170         mLoadingIndicatorDelay = mContent.getContext().getResources()
171                 .getInteger(R.integer.progress_indicator_delay);
172         mSetFocusAreaHighlightBottom = mContent.getContext().getResources().getBoolean(
173                 R.bool.set_browse_list_focus_area_highlight_above_minimized_control_bar);
174 
175         mFocusArea = mContent.findViewById(R.id.focus_area);
176         mBrowseList = mContent.findViewById(R.id.browse_list);
177         mErrorIcon = mContent.findViewById(R.id.error_icon);
178         mMessage = mContent.findViewById(R.id.error_message);
179         mFadeDuration = mContent.getContext().getResources().getInteger(
180                 R.integer.new_album_art_fade_in_duration);
181 
182 
183         FragmentActivity activity = callbacks.getActivity();
184         mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
185 
186         BrowseAdapter browseAdapter = new BrowseAdapter(mBrowseList.getContext());
187         mLimitedBrowseAdapter = new LimitedBrowseAdapter(mBrowseList, browseAdapter,
188                 mBrowseAdapterObserver);
189         mBrowseList.setAdapter(mLimitedBrowseAdapter);
190 
191         mUxrContentLimiter = new LifeCycleObserverUxrContentLimiter(
192                 new UxrContentLimiterImpl(activity, R.xml.uxr_config));
193         mUxrContentLimiter.setAdapter(mLimitedBrowseAdapter);
194         activity.getLifecycle().addObserver(mUxrContentLimiter);
195 
196         browseAdapter.setRootBrowsableViewType(rootBrowsableHint);
197         browseAdapter.setRootPlayableViewType(rootPlayableHint);
198 
199         mMediaItems.observe(activity, mItemsObserver);
200     }
201 
getParentItem()202     public MediaItemMetadata getParentItem() {
203         return mParentItem;
204     }
205 
206     /** Shares the browse adapter with the given view... #local-hack. */
shareBrowseAdapterWith(RecyclerView view)207     public void shareBrowseAdapterWith(RecyclerView view) {
208         view.setAdapter(mLimitedBrowseAdapter);
209     }
210 
211     private final Observer<FutureData<List<MediaItemMetadata>>> mItemsObserver =
212             this::onItemsUpdate;
213 
getContent()214     View getContent() {
215         return mContent;
216     }
217 
getDebugInfo()218     String getDebugInfo() {
219         StringBuilder log = new StringBuilder();
220         log.append("[");
221         log.append((mParentItem != null) ? mParentItem.getTitle() : "Root");
222         log.append("]");
223         FutureData<List<MediaItemMetadata>> children = mMediaItems.getValue();
224         if (children == null) {
225             log.append(" null future data");
226         } else if (children.isLoading()) {
227             log.append(" loading");
228         } else if (children.getData() == null) {
229             log.append(" null list");
230         } else {
231             List<MediaItemMetadata> nodes = children.getData();
232             log.append(" ");
233             log.append(nodes.size());
234             log.append(" {");
235             if (nodes.size() > 0) {
236                 log.append(nodes.get(0).getTitle().toString());
237             }
238             if (nodes.size() > 1) {
239                 log.append(", ");
240                 log.append(nodes.get(1).getTitle().toString());
241             }
242             if (nodes.size() > 2) {
243                 log.append(", ...");
244             }
245             log.append(" }");
246         }
247         return log.toString();
248     }
249 
destroy()250     void destroy() {
251         mCallbacks.getActivity().getLifecycle().removeObserver(mUxrContentLimiter);
252         mMediaItems.removeObserver(mItemsObserver);
253         removeFromParent(mContent);
254     }
255 
256     private Runnable mLoadingIndicatorRunnable = new Runnable() {
257         @Override
258         public void run() {
259             mMessage.setText(R.string.browser_loading);
260             ViewUtils.showViewAnimated(mMessage, mFadeDuration);
261         }
262     };
263 
startLoadingIndicator()264     private void startLoadingIndicator() {
265         // Display the indicator after a certain time, to avoid flashing the indicator constantly,
266         // even when performance is acceptable.
267         mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay);
268     }
269 
stopLoadingIndicator()270     private void stopLoadingIndicator() {
271         mHandler.removeCallbacks(mLoadingIndicatorRunnable);
272         ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
273     }
274 
onCarUiInsetsChanged(@onNull Insets insets)275     public void onCarUiInsetsChanged(@NonNull Insets insets) {
276         int leftPadding = mBrowseList.getPaddingLeft();
277         int rightPadding = mBrowseList.getPaddingRight();
278         int bottomPadding = mBrowseList.getPaddingBottom();
279         mBrowseList.setPadding(leftPadding, insets.getTop(), rightPadding, bottomPadding);
280         if (bottomPadding > mFocusAreaHighlightBottomPadding) {
281             mFocusAreaHighlightBottomPadding = bottomPadding;
282         }
283         mFocusArea.setHighlightPadding(
284                 leftPadding, insets.getTop(), rightPadding, mFocusAreaHighlightBottomPadding);
285         mFocusArea.setBoundsOffset(leftPadding, insets.getTop(), rightPadding, bottomPadding);
286     }
287 
onPlaybackControlsChanged(boolean visible)288     void onPlaybackControlsChanged(boolean visible) {
289         int leftPadding = mBrowseList.getPaddingLeft();
290         int topPadding = mBrowseList.getPaddingTop();
291         int rightPadding = mBrowseList.getPaddingRight();
292         Resources res = getActivity().getResources();
293         int bottomPadding = visible
294                 ? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding) : 0;
295         mBrowseList.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
296         int highlightBottomPadding = mSetFocusAreaHighlightBottom ? bottomPadding : 0;
297         if (highlightBottomPadding > mFocusAreaHighlightBottomPadding) {
298             mFocusAreaHighlightBottomPadding = highlightBottomPadding;
299         }
300         mFocusArea.setHighlightPadding(
301                 leftPadding, topPadding, rightPadding, mFocusAreaHighlightBottomPadding);
302         // Set the bottom offset to bottomPadding regardless of mSetFocusAreaHighlightBottom so that
303         // RotaryService can find the correct target when the user nudges the rotary controller.
304         mFocusArea.setBoundsOffset(leftPadding, topPadding, rightPadding, bottomPadding);
305 
306         ViewGroup.MarginLayoutParams messageLayout =
307                 (ViewGroup.MarginLayoutParams) mMessage.getLayoutParams();
308         messageLayout.bottomMargin = bottomPadding;
309         mMessage.setLayoutParams(messageLayout);
310     }
311 
getErrorMessage()312     private String getErrorMessage() {
313         if (/*root*/ !mDisplayMediaItems) {
314             MediaSource mediaSource = mViewModel.getMediaSourceValue();
315             return getActivity().getString(
316                     R.string.cannot_connect_to_app,
317                     mediaSource != null
318                             ? mediaSource.getDisplayName()
319                             : getActivity().getString(
320                                     R.string.unknown_media_provider_name));
321         } else {
322             return getActivity().getString(R.string.unknown_error);
323         }
324     }
325 
onItemsUpdate(@ullable FutureData<List<MediaItemMetadata>> futureData)326     private void onItemsUpdate(@Nullable FutureData<List<MediaItemMetadata>> futureData) {
327         if (futureData == null || futureData.isLoading()) {
328             ViewUtils.hideViewAnimated(mErrorIcon, 0);
329             ViewUtils.hideViewAnimated(mMessage, 0);
330 
331             // TODO(b/139759881) build a jank-free animation of the transition.
332             mBrowseList.setAlpha(0f);
333             mLimitedBrowseAdapter.submitItems(null, null);
334 
335             if (futureData != null) {
336                 startLoadingIndicator();
337             }
338             return;
339         }
340 
341         stopLoadingIndicator();
342 
343         List<MediaItemMetadata> items = MediaBrowserViewModelImpl.filterItems(
344                 /*root*/ !mDisplayMediaItems, futureData.getData());
345         if (mDisplayMediaItems) {
346             mLimitedBrowseAdapter.submitItems(mParentItem, items);
347 
348             List<MediaItemMetadata> lastNodes =
349                     MediaBrowserViewModelImpl.selectBrowseableItems(futureData.getPastData());
350             Collection<MediaItemMetadata> removedNodes =
351                     MediaBrowserViewModelImpl.computeRemovedItems(lastNodes, items);
352             if (!removedNodes.isEmpty()) {
353                 mCallbacks.onChildrenNodesRemoved(this, removedNodes);
354             }
355         }
356 
357         int duration = mFadeDuration;
358         if (items == null) {
359             mMessage.setText(getErrorMessage());
360             ViewUtils.hideViewAnimated(mBrowseList.getView(), duration);
361             ViewUtils.showViewAnimated(mMessage, duration);
362             ViewUtils.showViewAnimated(mErrorIcon, duration);
363         } else if (items.isEmpty()) {
364             mMessage.setText(R.string.nothing_to_play);
365             ViewUtils.hideViewAnimated(mBrowseList.getView(), duration);
366             ViewUtils.hideViewAnimated(mErrorIcon, duration);
367             ViewUtils.showViewAnimated(mMessage, duration);
368         } else {
369             ViewUtils.showViewAnimated(mBrowseList.getView(), duration);
370             ViewUtils.hideViewAnimated(mErrorIcon, duration);
371             ViewUtils.hideViewAnimated(mMessage, duration);
372         }
373 
374         if (Log.isLoggable(TAG, Log.VERBOSE)) {
375             Log.v(TAG, "onItemsUpdate " + getDebugInfo());
376         }
377     }
378 }
379