1 /* 2 * Copyright (C) 2020 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.showHideViewAnimated; 20 21 import android.car.content.pm.CarPackageManager; 22 import android.content.Context; 23 import android.support.v4.media.MediaBrowserCompat; 24 import android.util.Log; 25 import android.view.View; 26 import android.view.ViewGroup; 27 import android.view.inputmethod.InputMethodManager; 28 29 import androidx.annotation.NonNull; 30 import androidx.annotation.Nullable; 31 import androidx.fragment.app.FragmentActivity; 32 import androidx.lifecycle.Observer; 33 import androidx.lifecycle.ViewModelProviders; 34 import androidx.recyclerview.widget.LinearLayoutManager; 35 import androidx.recyclerview.widget.RecyclerView; 36 37 import com.android.car.apps.common.util.ViewUtils; 38 import com.android.car.apps.common.util.ViewUtils.ViewAnimEndListener; 39 import com.android.car.arch.common.FutureData; 40 import com.android.car.media.common.MediaItemMetadata; 41 import com.android.car.media.common.browse.MediaBrowserViewModelImpl; 42 import com.android.car.media.common.browse.MediaItemsRepository; 43 import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData; 44 import com.android.car.media.common.source.MediaBrowserConnector.BrowsingState; 45 import com.android.car.media.common.source.MediaSource; 46 import com.android.car.media.widgets.AppBarController; 47 import com.android.car.ui.baselayout.Insets; 48 import com.android.car.ui.toolbar.Toolbar; 49 50 import java.util.ArrayList; 51 import java.util.Collection; 52 import java.util.HashMap; 53 import java.util.List; 54 import java.util.Map; 55 import java.util.Objects; 56 import java.util.Stack; 57 58 /** 59 * Controls the views of the {@link MediaActivity}. 60 * TODO: finish moving control code out of MediaActivity (b/179292809). 61 */ 62 public class MediaActivityController extends ViewControllerBase { 63 64 private static final String TAG = "MediaActivityCtr"; 65 66 private final MediaItemsRepository mMediaItemsRepository; 67 private final Callbacks mCallbacks; 68 private final ViewGroup mBrowseArea; 69 private Insets mCarUiInsets; 70 private boolean mPlaybackControlsVisible; 71 72 private final Map<MediaItemMetadata, BrowseViewController> mBrowseViewControllersByNode = 73 new HashMap<>(); 74 75 // Controllers that should be destroyed once their view is hidden. 76 private final Map<View, BrowseViewController> mBrowseViewControllersToDestroy = new HashMap<>(); 77 78 private final BrowseViewController mRootLoadingController; 79 private final BrowseViewController mSearchResultsController; 80 81 /** 82 * Stores the reference to {@link MediaActivity.ViewModel#getBrowseStack}. 83 * Updated in {@link #onMediaSourceChanged}. 84 */ 85 private Stack<MediaItemMetadata> mBrowseStack; 86 /** 87 * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack}. 88 * Updated in {@link #onMediaSourceChanged}. 89 */ 90 private Stack<MediaItemMetadata> mSearchStack; 91 private final MediaActivity.ViewModel mViewModel; 92 93 private int mRootBrowsableHint; 94 private int mRootPlayableHint; 95 private boolean mBrowseTreeHasChildren; 96 private boolean mAcceptTabSelection = true; 97 98 /** 99 * Media items to display as tabs. If null, it means we haven't finished loading them yet. If 100 * empty, it means there are no tabs to show 101 */ 102 @Nullable 103 private List<MediaItemMetadata> mTopItems; 104 105 private final Observer<BrowsingState> mMediaBrowsingObserver = 106 this::onMediaBrowsingStateChanged; 107 108 /** 109 * Callbacks (implemented by the hosting Activity) 110 */ 111 public interface Callbacks { 112 113 /** Invoked when the user clicks on a browsable item. */ onPlayableItemClicked(@onNull MediaItemMetadata item)114 void onPlayableItemClicked(@NonNull MediaItemMetadata item); 115 116 /** Called once the list of the root node's children has been loaded. */ onRootLoaded()117 void onRootLoaded(); 118 119 /** Returns the activity. */ getActivity()120 FragmentActivity getActivity(); 121 } 122 123 /** 124 * Moves the user one level up in the browse/search tree. Returns whether that was possible. 125 */ navigateBack()126 private boolean navigateBack() { 127 boolean result = false; 128 if (!isAtTopStack()) { 129 hideAndDestroyControllerForItem(getStack().pop()); 130 131 // Show the parent (if any) 132 showCurrentNode(true); 133 134 if (isAtTopStack() && mViewModel.isSearching()) { 135 showSearchResults(true); 136 } 137 138 updateAppBar(); 139 result = true; 140 } 141 return result; 142 } 143 reopenSearch()144 private void reopenSearch() { 145 clearStack(mSearchStack); 146 showSearchResults(true); 147 updateAppBar(); 148 } 149 getActivity()150 private FragmentActivity getActivity() { 151 return mCallbacks.getActivity(); 152 } 153 154 /** Returns the browse or search stack. */ getStack()155 private Stack<MediaItemMetadata> getStack() { 156 return mViewModel.isSearching() ? mSearchStack : mBrowseStack; 157 } 158 159 /** 160 * @return whether the user is at the top of the browsing stack. 161 */ isAtTopStack()162 private boolean isAtTopStack() { 163 if (mViewModel.isSearching()) { 164 return mSearchStack.isEmpty(); 165 } else { 166 // The mBrowseStack stack includes the tab... 167 return mBrowseStack.size() <= 1; 168 } 169 } 170 clearMediaSource()171 private void clearMediaSource() { 172 showSearchMode(false); 173 for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { 174 controller.destroy(); 175 } 176 mBrowseViewControllersByNode.clear(); 177 mBrowseTreeHasChildren = false; 178 } 179 updateSearchQuery(@ullable String query)180 private void updateSearchQuery(@Nullable String query) { 181 mMediaItemsRepository.setSearchQuery(query); 182 } 183 184 /** 185 * Clears search state, removes any UI elements from previous results. 186 */ 187 @Override onMediaSourceChanged(@ullable MediaSource mediaSource)188 void onMediaSourceChanged(@Nullable MediaSource mediaSource) { 189 super.onMediaSourceChanged(mediaSource); 190 191 updateTabs((mediaSource != null) ? null : new ArrayList<>()); 192 193 mSearchStack = mViewModel.getSearchStack(); 194 mBrowseStack = mViewModel.getBrowseStack(); 195 196 updateAppBar(); 197 } 198 onMediaBrowsingStateChanged(BrowsingState newBrowsingState)199 private void onMediaBrowsingStateChanged(BrowsingState newBrowsingState) { 200 switch (newBrowsingState.mConnectionStatus) { 201 case CONNECTING: 202 break; 203 case CONNECTED: 204 MediaBrowserCompat browser = newBrowsingState.mBrowser; 205 mRootBrowsableHint = MediaBrowserViewModelImpl.getRootBrowsableHint(browser); 206 mRootPlayableHint = MediaBrowserViewModelImpl.getRootPlayableHint(browser); 207 208 boolean canSearch = MediaBrowserViewModelImpl.getSupportsSearch(browser); 209 mAppBarController.setSearchSupported(canSearch); 210 break; 211 212 case DISCONNECTING: 213 case REJECTED: 214 case SUSPENDED: 215 clearMediaSource(); 216 break; 217 } 218 219 MediaSource savedSource = mViewModel.getBrowsedMediaSource().getValue(); 220 MediaSource mediaSource = newBrowsingState.mMediaSource; 221 if (Log.isLoggable(TAG, Log.INFO)) { 222 Log.i(TAG, "MediaSource changed from " + savedSource + " to " + mediaSource); 223 } 224 225 mViewModel.saveBrowsedMediaSource(mediaSource); 226 onMediaSourceChanged(mediaSource); 227 } 228 229 MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo, CarPackageManager carPackageManager, ViewGroup container)230 MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo, 231 CarPackageManager carPackageManager, ViewGroup container) { 232 super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse); 233 234 FragmentActivity activity = callbacks.getActivity(); 235 mCallbacks = callbacks; 236 mMediaItemsRepository = mediaItemsRepo; 237 mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class); 238 mSearchStack = mViewModel.getSearchStack(); 239 mBrowseStack = mViewModel.getBrowseStack(); 240 mBrowseArea = mContent.requireViewById(R.id.browse_content_area); 241 242 MediaItemsLiveData rootMediaItems = mediaItemsRepo.getRootMediaItems(); 243 mRootLoadingController = BrowseViewController.newRootController( 244 mBrowseCallbacks, mBrowseArea, rootMediaItems); 245 mRootLoadingController.getContent().setAlpha(1f); 246 247 mSearchResultsController = BrowseViewController.newSearchResultsController( 248 mBrowseCallbacks, mBrowseArea, mMediaItemsRepository.getSearchMediaItems()); 249 250 boolean showingSearch = mViewModel.isShowingSearchResults(); 251 ViewUtils.setVisible(mSearchResultsController.getContent(), showingSearch); 252 if (showingSearch) { 253 mSearchResultsController.getContent().setAlpha(1f); 254 } 255 256 mAppBarController.setListener(mAppBarListener); 257 mAppBarController.setSearchQuery(mViewModel.getSearchQuery()); 258 if (mAppBarController.canShowSearchResultsView()) { 259 // TODO(b/180441965) eliminate the need to create a different view and use 260 // mSearchResultsController.getContent() instead. 261 RecyclerView toolbarSearchResultsView = new RecyclerView(activity); 262 mSearchResultsController.shareBrowseAdapterWith(toolbarSearchResultsView); 263 264 ViewGroup.LayoutParams params = new ViewGroup.LayoutParams( 265 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); 266 toolbarSearchResultsView.setLayoutParams(params); 267 toolbarSearchResultsView.setLayoutManager(new LinearLayoutManager(activity)); 268 toolbarSearchResultsView.setBackground( 269 activity.getDrawable(R.drawable.car_ui_ime_wide_screen_background)); 270 271 mAppBarController.setSearchResultsView(toolbarSearchResultsView); 272 } 273 274 updateAppBar(); 275 276 // Observe forever ensures the caches are destroyed even while the activity isn't resumed. 277 mediaItemsRepo.getBrowsingState().observeForever(mMediaBrowsingObserver); 278 279 rootMediaItems.observe(activity, this::onRootMediaItemsUpdate); 280 mViewModel.getMiniControlsVisible().observe(activity, this::onPlaybackControlsChanged); 281 } 282 onDestroy()283 void onDestroy() { 284 mMediaItemsRepository.getBrowsingState().removeObserver(mMediaBrowsingObserver); 285 } 286 287 private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() { 288 @Override 289 public void onTabSelected(MediaItemMetadata item) { 290 if (mAcceptTabSelection && (item != null) && (item != mViewModel.getSelectedTab())) { 291 clearStack(mBrowseStack); 292 mBrowseStack.push(item); 293 showCurrentNode(true); 294 } 295 } 296 297 @Override 298 public void onSearchSelection() { 299 if (mViewModel.isSearching()) { 300 reopenSearch(); 301 } else { 302 showSearchMode(true); 303 updateAppBar(); 304 } 305 } 306 307 @Override 308 public void onSearch(String query) { 309 if (Log.isLoggable(TAG, Log.DEBUG)) { 310 Log.d(TAG, "onSearch: " + query); 311 } 312 mViewModel.setSearchQuery(query); 313 updateSearchQuery(query); 314 } 315 }; 316 317 private final BrowseViewController.Callbacks mBrowseCallbacks = 318 new BrowseViewController.Callbacks() { 319 @Override 320 public void onPlayableItemClicked(@NonNull MediaItemMetadata item) { 321 hideKeyboard(); 322 mCallbacks.onPlayableItemClicked(item); 323 } 324 325 @Override 326 public void onBrowsableItemClicked(@NonNull MediaItemMetadata item) { 327 hideKeyboard(); 328 navigateInto(item); 329 } 330 331 @Override 332 public void onChildrenNodesRemoved(@NonNull BrowseViewController controller, 333 @NonNull Collection<MediaItemMetadata> removedNodes) { 334 335 if (mBrowseStack.contains(controller.getParentItem())) { 336 for (MediaItemMetadata node : removedNodes) { 337 int indexOfNode = mBrowseStack.indexOf(node); 338 if (indexOfNode >= 0) { 339 clearStack(mBrowseStack.subList(indexOfNode, mBrowseStack.size())); 340 if (!mViewModel.isShowingSearchResults()) { 341 showCurrentNode(true); 342 updateAppBar(); 343 } 344 break; // The stack contains at most one of the removed nodes. 345 } 346 } 347 } 348 } 349 350 @Override 351 public FragmentActivity getActivity() { 352 return mCallbacks.getActivity(); 353 } 354 }; 355 356 private final ViewAnimEndListener mViewAnimEndListener = view -> { 357 BrowseViewController toDestroy = mBrowseViewControllersToDestroy.remove(view); 358 if (toDestroy != null) { 359 toDestroy.destroy(); 360 } 361 }; 362 onBackPressed()363 boolean onBackPressed() { 364 boolean success = navigateBack(); 365 if (!success && mViewModel.isSearching()) { 366 showSearchMode(false); 367 updateAppBar(); 368 return true; 369 } 370 return success; 371 } 372 browseTreeHasChildren()373 boolean browseTreeHasChildren() { 374 return mBrowseTreeHasChildren; 375 } 376 navigateInto(@onNull MediaItemMetadata item)377 private void navigateInto(@NonNull MediaItemMetadata item) { 378 showSearchResults(false); 379 380 // Hide the current node (parent) 381 showCurrentNode(false); 382 383 // Make item the current node 384 getStack().push(item); 385 386 // Show the current node (item) 387 showCurrentNode(true); 388 389 updateAppBar(); 390 } 391 getControllerForItem(@onNull MediaItemMetadata item)392 private BrowseViewController getControllerForItem(@NonNull MediaItemMetadata item) { 393 BrowseViewController controller = mBrowseViewControllersByNode.get(item); 394 if (controller == null) { 395 controller = BrowseViewController.newBrowseController(mBrowseCallbacks, mBrowseArea, 396 item, mMediaItemsRepository.getMediaChildren(item.getId()), mRootBrowsableHint, 397 mRootPlayableHint); 398 399 if (mCarUiInsets != null) { 400 controller.onCarUiInsetsChanged(mCarUiInsets); 401 } 402 controller.onPlaybackControlsChanged(mPlaybackControlsVisible); 403 404 mBrowseViewControllersByNode.put(item, controller); 405 } 406 return controller; 407 } 408 showCurrentNode(boolean show)409 private void showCurrentNode(boolean show) { 410 MediaItemMetadata currentNode = getCurrentMediaItem(); 411 if (currentNode == null) { 412 return; 413 } 414 // Only create a controller to show it. 415 BrowseViewController controller = show ? getControllerForItem(currentNode) : 416 mBrowseViewControllersByNode.get(currentNode); 417 418 if (controller != null) { 419 showHideViewAnimated(show, controller.getContent(), mFadeDuration, 420 mViewAnimEndListener); 421 } 422 } 423 showSearchResults(boolean show)424 private void showSearchResults(boolean show) { 425 if (mViewModel.isShowingSearchResults() != show) { 426 mViewModel.setShowingSearchResults(show); 427 showHideViewAnimated(show, mSearchResultsController.getContent(), mFadeDuration, null); 428 } 429 } 430 showSearchMode(boolean show)431 private void showSearchMode(boolean show) { 432 if (mViewModel.isSearching() != show) { 433 if (show) { 434 showCurrentNode(false); 435 } 436 437 mViewModel.setSearching(show); 438 showSearchResults(show); 439 440 if (!show) { 441 showCurrentNode(true); 442 } 443 } 444 } 445 446 /** 447 * @return the current item being displayed 448 */ 449 @Nullable getCurrentMediaItem()450 private MediaItemMetadata getCurrentMediaItem() { 451 Stack<MediaItemMetadata> stack = getStack(); 452 return stack.isEmpty() ? null : stack.lastElement(); 453 } 454 455 @Override onCarUiInsetsChanged(@onNull Insets insets)456 public void onCarUiInsetsChanged(@NonNull Insets insets) { 457 mCarUiInsets = insets; 458 for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { 459 controller.onCarUiInsetsChanged(mCarUiInsets); 460 } 461 mRootLoadingController.onCarUiInsetsChanged(mCarUiInsets); 462 mSearchResultsController.onCarUiInsetsChanged(mCarUiInsets); 463 } 464 onPlaybackControlsChanged(boolean visible)465 void onPlaybackControlsChanged(boolean visible) { 466 mPlaybackControlsVisible = visible; 467 for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { 468 controller.onPlaybackControlsChanged(mPlaybackControlsVisible); 469 } 470 mRootLoadingController.onPlaybackControlsChanged(mPlaybackControlsVisible); 471 mSearchResultsController.onPlaybackControlsChanged(mPlaybackControlsVisible); 472 } 473 hideKeyboard()474 private void hideKeyboard() { 475 InputMethodManager in = 476 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); 477 in.hideSoftInputFromWindow(mContent.getWindowToken(), 0); 478 } 479 hideAndDestroyControllerForItem(@ullable MediaItemMetadata item)480 private void hideAndDestroyControllerForItem(@Nullable MediaItemMetadata item) { 481 if (item == null) { 482 return; 483 } 484 BrowseViewController controller = mBrowseViewControllersByNode.get(item); 485 if (controller == null) { 486 return; 487 } 488 489 if (controller.getContent().getVisibility() == View.VISIBLE) { 490 View view = controller.getContent(); 491 mBrowseViewControllersToDestroy.put(view, controller); 492 showHideViewAnimated(false, view, mFadeDuration, mViewAnimEndListener); 493 } else { 494 controller.destroy(); 495 } 496 mBrowseViewControllersByNode.remove(item); 497 } 498 499 /** 500 * Clears the given stack (or a portion of a stack) and destroys the old controllers (after 501 * their view is hidden). 502 */ clearStack(List<MediaItemMetadata> stack)503 private void clearStack(List<MediaItemMetadata> stack) { 504 for (MediaItemMetadata item : stack) { 505 hideAndDestroyControllerForItem(item); 506 } 507 stack.clear(); 508 } 509 510 /** 511 * Updates the tabs displayed on the app bar, based on the top level items on the browse tree. 512 * If there is at least one browsable item, we show the browse content of that node. If there 513 * are only playable items, then we show those items. If there are not items at all, we show the 514 * empty message. If we receive null, we show the error message. 515 * 516 * @param items top level items, null if the items are still being loaded, or empty list if 517 * items couldn't be loaded. 518 */ updateTabs(@ullable List<MediaItemMetadata> items)519 private void updateTabs(@Nullable List<MediaItemMetadata> items) { 520 if (Objects.equals(mTopItems, items)) { 521 // When coming back to the app, the live data sends an update even if the list hasn't 522 // changed. Updating the tabs then recreates the browse view, which produces jank 523 // (b/131830876), and also resets the navigation to the top of the first tab... 524 return; 525 } 526 mTopItems = items; 527 528 if (mTopItems == null || mTopItems.isEmpty()) { 529 mAppBarController.setItems(null); 530 mAppBarController.setActiveItem(null); 531 if (items != null) { 532 // Only do this when not loading the tabs or we loose the saved one. 533 clearStack(mBrowseStack); 534 } 535 updateAppBar(); 536 return; 537 } 538 539 MediaItemMetadata oldTab = mViewModel.getSelectedTab(); 540 MediaItemMetadata newTab = items.contains(oldTab) ? oldTab : items.get(0); 541 542 try { 543 mAcceptTabSelection = false; 544 mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems); 545 mAppBarController.setActiveItem(newTab); 546 547 if (oldTab != newTab) { 548 // Tabs belong to the browse stack. 549 clearStack(mBrowseStack); 550 mBrowseStack.push(newTab); 551 } 552 553 if (!mViewModel.isShowingSearchResults()) { 554 // Needed when coming back to an app after a config change or from another app, 555 // or when the tab actually changes. 556 showCurrentNode(true); 557 } 558 } finally { 559 mAcceptTabSelection = true; 560 } 561 updateAppBar(); 562 } 563 updateAppBarTitle()564 private void updateAppBarTitle() { 565 boolean isStacked = !isAtTopStack(); 566 567 final CharSequence title; 568 if (isStacked) { 569 // If not at top level, show the current item as title 570 title = getCurrentMediaItem().getTitle(); 571 } else if (mTopItems == null) { 572 // If still loading the tabs, force to show an empty bar. 573 title = ""; 574 } else if (mTopItems.size() == 1) { 575 // If we finished loading tabs and there is only one, use that as title. 576 title = mTopItems.get(0).getTitle(); 577 } else { 578 // Otherwise (no tabs or more than 1 tabs), show the current media source title. 579 MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue(); 580 title = getAppBarDefaultTitle(mediaSource); 581 } 582 583 mAppBarController.setTitle(title); 584 } 585 586 /** 587 * Update elements of the appbar that change depending on where we are in the browse. 588 */ updateAppBar()589 private void updateAppBar() { 590 boolean isSearching = mViewModel.isSearching(); 591 boolean isStacked = !isAtTopStack(); 592 if (Log.isLoggable(TAG, Log.DEBUG)) { 593 Log.d(TAG, "App bar is in stacked state: " + isStacked); 594 } 595 Toolbar.State unstackedState = isSearching ? Toolbar.State.SEARCH : Toolbar.State.HOME; 596 updateAppBarTitle(); 597 mAppBarController.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState); 598 mAppBarController.showSearchIfSupported(!isSearching || isStacked); 599 } 600 onRootMediaItemsUpdate(FutureData<List<MediaItemMetadata>> data)601 private void onRootMediaItemsUpdate(FutureData<List<MediaItemMetadata>> data) { 602 if (data.isLoading()) { 603 if (Log.isLoggable(TAG, Log.INFO)) { 604 Log.i(TAG, "Loading browse tree..."); 605 } 606 mBrowseTreeHasChildren = false; 607 updateTabs(null); 608 return; 609 } 610 611 List<MediaItemMetadata> items = 612 MediaBrowserViewModelImpl.filterItems(/*forRoot*/ true, data.getData()); 613 614 boolean browseTreeHasChildren = items != null && !items.isEmpty(); 615 if (Log.isLoggable(TAG, Log.INFO)) { 616 Log.i(TAG, "Browse tree loaded, status (has children or not) changed: " 617 + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren); 618 } 619 mBrowseTreeHasChildren = browseTreeHasChildren; 620 mCallbacks.onRootLoaded(); 621 updateTabs(items != null ? items : new ArrayList<>()); 622 } 623 624 } 625