/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.car.media; import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS; import static com.android.car.apps.common.util.ViewUtils.showHideViewAnimated; import static com.android.car.ui.utils.ViewUtils.LazyLayoutView; import android.car.content.pm.CarPackageManager; import android.content.Context; import android.support.v4.media.MediaBrowserCompat; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.android.car.apps.common.util.FutureData; import com.android.car.apps.common.util.ViewUtils; import com.android.car.apps.common.util.ViewUtils.ViewAnimEndListener; import com.android.car.media.common.MediaItemMetadata; import com.android.car.media.common.browse.MediaBrowserViewModelImpl; import com.android.car.media.common.browse.MediaItemsRepository; import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData; import com.android.car.media.common.source.MediaBrowserConnector.BrowsingState; import com.android.car.media.common.source.MediaSource; import com.android.car.media.widgets.AppBarController; import com.android.car.ui.FocusParkingView; import com.android.car.ui.baselayout.Insets; import com.android.car.ui.recyclerview.CarUiRecyclerView; import com.android.car.ui.toolbar.NavButtonMode; import com.android.car.ui.toolbar.SearchConfig; import com.android.car.ui.toolbar.SearchMode; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Stack; /** * Controls the views of the {@link MediaActivity}. * TODO: finish moving control code out of MediaActivity (b/179292809). */ public class MediaActivityController extends ViewControllerBase { private static final String TAG = "MediaActivityCtr"; private final MediaItemsRepository mMediaItemsRepository; private final Callbacks mCallbacks; private final ViewGroup mBrowseArea; private final FocusParkingView mFpv; private Insets mCarUiInsets; private boolean mPlaybackControlsVisible; private final Map mBrowseViewControllersByNode = new HashMap<>(); // Controllers that should be destroyed once their view is hidden. private final Map mBrowseViewControllersToDestroy = new HashMap<>(); private final BrowseViewController mRootLoadingController; private final BrowseViewController mSearchResultsController; /** * Stores the reference to {@link MediaActivity.ViewModel#getBrowseStack}. * Updated in {@link #onMediaSourceChanged}. */ private Stack mBrowseStack; /** * Stores the reference to {@link MediaActivity.ViewModel#getSearchStack}. * Updated in {@link #onMediaSourceChanged}. */ private Stack mSearchStack; private final MediaActivity.ViewModel mViewModel; private int mRootBrowsableHint; private int mRootPlayableHint; private boolean mBrowseTreeHasChildren; private boolean mAcceptTabSelection = true; /** * Media items to display as tabs. If null, it means we haven't finished loading them yet. If * empty, it means there are no tabs to show */ @Nullable private List mTopItems; private final Observer mMediaBrowsingObserver = this::onMediaBrowsingStateChanged; /** * Callbacks (implemented by the hosting Activity) */ public interface Callbacks { /** Invoked when the user clicks on a browsable item. */ void onPlayableItemClicked(@NonNull MediaItemMetadata item); /** Called once the list of the root node's children has been loaded. */ void onRootLoaded(); /** Returns the activity. */ FragmentActivity getActivity(); } /** * Moves the user one level up in the browse/search tree. Returns whether that was possible. */ private boolean navigateBack() { boolean result = false; if (!isAtTopStack()) { hideAndDestroyControllerForItem(getStack().pop()); // Show the parent (if any) showCurrentNode(true); if (isAtTopStack() && mViewModel.isSearching()) { showSearchResults(true); } updateAppBar(); result = true; } return result; } private void reopenSearch() { clearStack(mSearchStack); showSearchResults(true); updateAppBar(); } private FragmentActivity getActivity() { return mCallbacks.getActivity(); } /** Returns the browse or search stack. */ private Stack getStack() { return mViewModel.isSearching() ? mSearchStack : mBrowseStack; } /** * @return whether the user is at the top of the browsing stack. */ private boolean isAtTopStack() { if (mViewModel.isSearching()) { return mSearchStack.isEmpty(); } else { // The mBrowseStack stack includes the tab... return mBrowseStack.size() <= 1; } } private void clearMediaSource() { showSearchMode(false); for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { controller.destroy(); } mBrowseViewControllersByNode.clear(); mBrowseTreeHasChildren = false; } private void updateSearchQuery(@Nullable String query) { mMediaItemsRepository.setSearchQuery(query); } /** * Clears search state, removes any UI elements from previous results. */ @Override void onMediaSourceChanged(@Nullable MediaSource mediaSource) { super.onMediaSourceChanged(mediaSource); updateTabs((mediaSource != null) ? null : new ArrayList<>()); mSearchStack = mViewModel.getSearchStack(); mBrowseStack = mViewModel.getBrowseStack(); updateAppBar(); } private void onMediaBrowsingStateChanged(BrowsingState newBrowsingState) { if (newBrowsingState == null) { Log.e(TAG, "Null browsing state (no media source!)"); return; } switch (newBrowsingState.mConnectionStatus) { case CONNECTING: break; case CONNECTED: MediaBrowserCompat browser = newBrowsingState.mBrowser; mRootBrowsableHint = MediaBrowserViewModelImpl.getRootBrowsableHint(browser); mRootPlayableHint = MediaBrowserViewModelImpl.getRootPlayableHint(browser); boolean canSearch = MediaBrowserViewModelImpl.getSupportsSearch(browser); mAppBarController.setSearchSupported(canSearch); break; case DISCONNECTING: case REJECTED: case SUSPENDED: clearMediaSource(); break; } mViewModel.saveBrowsedMediaSource(newBrowsingState.mMediaSource); } MediaActivityController(Callbacks callbacks, MediaItemsRepository mediaItemsRepo, CarPackageManager carPackageManager, ViewGroup container) { super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse); FragmentActivity activity = callbacks.getActivity(); mCallbacks = callbacks; mMediaItemsRepository = mediaItemsRepo; mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class); mSearchStack = mViewModel.getSearchStack(); mBrowseStack = mViewModel.getBrowseStack(); mBrowseArea = mContent.requireViewById(R.id.browse_content_area); mFpv = activity.requireViewById(R.id.fpv); MediaItemsLiveData rootMediaItems = mediaItemsRepo.getRootMediaItems(); mRootLoadingController = BrowseViewController.newRootController( mBrowseCallbacks, mBrowseArea, rootMediaItems); mRootLoadingController.getContent().setAlpha(1f); mSearchResultsController = BrowseViewController.newSearchResultsController( mBrowseCallbacks, mBrowseArea, mMediaItemsRepository.getSearchMediaItems()); boolean showingSearch = mViewModel.isShowingSearchResults(); ViewUtils.setVisible(mSearchResultsController.getContent(), showingSearch); if (showingSearch) { mSearchResultsController.getContent().setAlpha(1f); } mAppBarController.setListener(mAppBarListener); mAppBarController.setSearchQuery(mViewModel.getSearchQuery()); if (mAppBarController.getSearchCapabilities().canShowSearchResultsView()) { // TODO(b/180441965) eliminate the need to create a different view and use // mSearchResultsController.getContent() instead. RecyclerView toolbarSearchResultsView = new RecyclerView(activity); mSearchResultsController.shareBrowseAdapterWith(toolbarSearchResultsView); ViewGroup.LayoutParams params = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); toolbarSearchResultsView.setLayoutParams(params); toolbarSearchResultsView.setLayoutManager(new LinearLayoutManager(activity)); toolbarSearchResultsView.setBackground( activity.getDrawable(R.drawable.car_ui_ime_wide_screen_background)); mAppBarController.setSearchConfig(SearchConfig.builder() .setSearchResultsView(toolbarSearchResultsView) .build()); } updateAppBar(); // Observe forever ensures the caches are destroyed even while the activity isn't resumed. mediaItemsRepo.getBrowsingState().observeForever(mMediaBrowsingObserver); mViewModel.getBrowsedMediaSource().observeForever(future -> { onMediaSourceChanged(future.isLoading() ? null : future.getData()); }); rootMediaItems.observe(activity, this::onRootMediaItemsUpdate); mViewModel.getMiniControlsVisible().observe(activity, this::onPlaybackControlsChanged); } void onDestroy() { mMediaItemsRepository.getBrowsingState().removeObserver(mMediaBrowsingObserver); } private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() { @Override public void onTabSelected(MediaItemMetadata item) { if (mAcceptTabSelection && (item != null) && (item != mViewModel.getSelectedTab())) { clearStack(mBrowseStack); mBrowseStack.push(item); showCurrentNode(true); } } @Override public void onSearchSelection() { if (mViewModel.isSearching()) { reopenSearch(); } else { showSearchMode(true); updateAppBar(); } } @Override public void onSearch(String query) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "onSearch: " + query); } mViewModel.setSearchQuery(query); updateSearchQuery(query); } }; private final BrowseViewController.Callbacks mBrowseCallbacks = new BrowseViewController.Callbacks() { @Override public void onPlayableItemClicked(@NonNull MediaItemMetadata item) { hideKeyboard(); mCallbacks.onPlayableItemClicked(item); } @Override public void onBrowsableItemClicked(@NonNull MediaItemMetadata item) { hideKeyboard(); navigateInto(item); } @Override public void onChildrenNodesRemoved(@NonNull BrowseViewController controller, @NonNull Collection removedNodes) { if (mBrowseStack.contains(controller.getParentItem())) { for (MediaItemMetadata node : removedNodes) { int indexOfNode = mBrowseStack.indexOf(node); if (indexOfNode >= 0) { clearStack(mBrowseStack.subList(indexOfNode, mBrowseStack.size())); if (!mViewModel.isShowingSearchResults()) { showCurrentNode(true); updateAppBar(); } break; // The stack contains at most one of the removed nodes. } } } } @Override public FragmentActivity getActivity() { return mCallbacks.getActivity(); } }; private final ViewAnimEndListener mViewAnimEndListener = view -> { BrowseViewController toDestroy = mBrowseViewControllersToDestroy.remove(view); if (toDestroy != null) { toDestroy.destroy(); } }; boolean onBackPressed() { boolean success = navigateBack(); if (!success && mViewModel.isSearching()) { showSearchMode(false); updateAppBar(); success = true; } if (success) { // When the back button is pressed, if a CarUiRecyclerView shows up and it's in rotary // mode, restore focus in the CarUiRecyclerView. restoreFocusInCurrentNode(); } return success; } boolean browseTreeHasChildren() { return mBrowseTreeHasChildren; } private void navigateInto(@NonNull MediaItemMetadata item) { showSearchResults(false); // Hide the current node (parent) showCurrentNode(false); // Make item the current node getStack().push(item); // Show the current node (item) showCurrentNode(true); updateAppBar(); } private BrowseViewController getControllerForItem(@NonNull MediaItemMetadata item) { BrowseViewController controller = mBrowseViewControllersByNode.get(item); if (controller == null) { controller = BrowseViewController.newBrowseController(mBrowseCallbacks, mBrowseArea, item, mMediaItemsRepository.getMediaChildren(item.getId()), mRootBrowsableHint, mRootPlayableHint); if (mCarUiInsets != null) { controller.onCarUiInsetsChanged(mCarUiInsets); } controller.onPlaybackControlsChanged(mPlaybackControlsVisible); mBrowseViewControllersByNode.put(item, controller); } return controller; } private void showCurrentNode(boolean show) { MediaItemMetadata currentNode = getCurrentMediaItem(); if (currentNode == null) { return; } // Only create a controller to show it. BrowseViewController controller = show ? getControllerForItem(currentNode) : mBrowseViewControllersByNode.get(currentNode); if (controller != null) { showHideContentAnimated(show, controller.getContent(), mViewAnimEndListener); } } // If the current node has a CarUiRecyclerView and it's in rotary mode, restore focus in it. void restoreFocusInCurrentNode() { MediaItemMetadata currentNode = getCurrentMediaItem(); if (currentNode == null) { return; } BrowseViewController controller = getControllerForItem(currentNode); if (controller == null) { return; } CarUiRecyclerView carUiRecyclerView = controller.getContent().findViewById(R.id.browse_list); if (carUiRecyclerView != null && carUiRecyclerView instanceof LazyLayoutView && !carUiRecyclerView.getView().hasFocus() && !carUiRecyclerView.getView().isInTouchMode()) { // Park the focus on the FocusParkingView to ensure that it can restore focus inside // the LazyLayoutView successfully later. mFpv.performAccessibilityAction(ACTION_FOCUS, null); LazyLayoutView lazyLayoutView = (LazyLayoutView) carUiRecyclerView; com.android.car.ui.utils.ViewUtils.initFocus(lazyLayoutView); } } private void showHideContentAnimated(boolean show, @NonNull View content, @Nullable ViewAnimEndListener listener) { CarUiRecyclerView carUiRecyclerView = content.findViewById(R.id.browse_list); if (carUiRecyclerView != null && carUiRecyclerView instanceof LazyLayoutView && !carUiRecyclerView.getView().isInTouchMode()) { // If a CarUiRecyclerView is about to hide and it has focus, park the focus on the // FocusParkingView before hiding the CarUiRecyclerView. Otherwise hiding the focused // view will cause the Android framework to move focus to another view, causing visual // jank. if (!show && carUiRecyclerView.getView().hasFocus()) { mFpv.performAccessibilityAction(ACTION_FOCUS, null); } // If a new CarUiRecyclerView is about to show and there is no view focused or the // FocusParkingView is focused, restore focus in the new CarUiRecyclerView. if (show) { View focusedView = carUiRecyclerView.getView().getRootView().findFocus(); if (focusedView == null || focusedView instanceof FocusParkingView) { LazyLayoutView lazyLayoutView = (LazyLayoutView) carUiRecyclerView; com.android.car.ui.utils.ViewUtils.initFocus(lazyLayoutView); } } } showHideViewAnimated(show, content, mFadeDuration, listener); } private void showSearchResults(boolean show) { if (mViewModel.isShowingSearchResults() != show) { mViewModel.setShowingSearchResults(show); showHideContentAnimated(show, mSearchResultsController.getContent(), null); } } private void showSearchMode(boolean show) { if (mViewModel.isSearching() != show) { if (show) { showCurrentNode(false); } mViewModel.setSearching(show); showSearchResults(show); if (!show) { showCurrentNode(true); } } } /** * @return the current item being displayed */ @Nullable private MediaItemMetadata getCurrentMediaItem() { Stack stack = getStack(); return stack.isEmpty() ? null : stack.lastElement(); } @Override public void onCarUiInsetsChanged(@NonNull Insets insets) { mCarUiInsets = insets; for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { controller.onCarUiInsetsChanged(mCarUiInsets); } mRootLoadingController.onCarUiInsetsChanged(mCarUiInsets); mSearchResultsController.onCarUiInsetsChanged(mCarUiInsets); } void onPlaybackControlsChanged(boolean visible) { mPlaybackControlsVisible = visible; for (BrowseViewController controller : mBrowseViewControllersByNode.values()) { controller.onPlaybackControlsChanged(mPlaybackControlsVisible); } mRootLoadingController.onPlaybackControlsChanged(mPlaybackControlsVisible); mSearchResultsController.onPlaybackControlsChanged(mPlaybackControlsVisible); } private void hideKeyboard() { InputMethodManager in = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); in.hideSoftInputFromWindow(mContent.getWindowToken(), 0); } private void hideAndDestroyControllerForItem(@Nullable MediaItemMetadata item) { if (item == null) { return; } BrowseViewController controller = mBrowseViewControllersByNode.get(item); if (controller == null) { return; } if (controller.getContent().getVisibility() == View.VISIBLE) { View view = controller.getContent(); mBrowseViewControllersToDestroy.put(view, controller); showHideContentAnimated(false, view, mViewAnimEndListener); } else { controller.destroy(); } mBrowseViewControllersByNode.remove(item); } /** * Clears the given stack (or a portion of a stack) and destroys the old controllers (after * their view is hidden). */ private void clearStack(List stack) { for (MediaItemMetadata item : stack) { hideAndDestroyControllerForItem(item); } stack.clear(); } /** * Updates the tabs displayed on the app bar, based on the top level items on the browse tree. * If there is at least one browsable item, we show the browse content of that node. If there * are only playable items, then we show those items. If there are not items at all, we show the * empty message. If we receive null, we show the error message. * * @param items top level items, null if the items are still being loaded, or empty list if * items couldn't be loaded. */ private void updateTabs(@Nullable List items) { if (Objects.equals(mTopItems, items)) { // When coming back to the app, the live data sends an update even if the list hasn't // changed. Updating the tabs then recreates the browse view, which produces jank // (b/131830876), and also resets the navigation to the top of the first tab... return; } mTopItems = items; if (mTopItems == null || mTopItems.isEmpty()) { mAppBarController.setItems(null); mAppBarController.setActiveItem(null); if (items != null) { // Only do this when not loading the tabs or we loose the saved one. clearStack(mBrowseStack); } updateAppBar(); return; } MediaItemMetadata oldTab = mViewModel.getSelectedTab(); MediaItemMetadata newTab = items.contains(oldTab) ? oldTab : items.get(0); try { mAcceptTabSelection = false; mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems); mAppBarController.setActiveItem(newTab); if (oldTab != newTab) { // Tabs belong to the browse stack. clearStack(mBrowseStack); mBrowseStack.push(newTab); } if (!mViewModel.isShowingSearchResults()) { // Needed when coming back to an app after a config change or from another app, // or when the tab actually changes. showCurrentNode(true); } } finally { mAcceptTabSelection = true; } updateAppBar(); } private CharSequence getAppBarTitle() { boolean isStacked = !isAtTopStack(); final CharSequence title; if (isStacked) { // If not at top level, show the current item as title MediaItemMetadata item = getCurrentMediaItem(); title = item != null ? item.getTitle() : ""; } else if (mTopItems == null) { // If still loading the tabs, force to show an empty bar. title = ""; } else if (mTopItems.size() == 1) { // If we finished loading tabs and there is only one, use that as title. title = mTopItems.get(0).getTitle(); } else { // Otherwise (no tabs or more than 1 tabs), show the current media source title. MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue(); title = getAppBarDefaultTitle(mediaSource); } return title; } /** * Update elements of the appbar that change depending on where we are in the browse. */ private void updateAppBar() { boolean isSearching = mViewModel.isSearching(); boolean isStacked = !isAtTopStack(); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "App bar is in stacked state: " + isStacked); } mAppBarController.setSearchMode(isSearching ? SearchMode.SEARCH : SearchMode.DISABLED); mAppBarController.setNavButtonMode(isStacked || isSearching ? NavButtonMode.BACK : NavButtonMode.DISABLED); mAppBarController.setTitle(!isSearching ? getAppBarTitle() : null); mAppBarController.showSearchIfSupported(!isSearching || isStacked); } private void onRootMediaItemsUpdate(FutureData> data) { if (data.isLoading()) { if (Log.isLoggable(TAG, Log.INFO)) { Log.i(TAG, "Loading browse tree..."); } mBrowseTreeHasChildren = false; updateTabs(null); return; } List items = MediaBrowserViewModelImpl.filterItems(/*forRoot*/ true, data.getData()); boolean browseTreeHasChildren = items != null && !items.isEmpty(); if (Log.isLoggable(TAG, Log.INFO)) { Log.i(TAG, "Browse tree loaded, status (has children or not) changed: " + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren); } mBrowseTreeHasChildren = browseTreeHasChildren; mCallbacks.onRootLoaded(); updateTabs(items != null ? items : new ArrayList<>()); } }