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