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