blob: cd67f714c3d1386e2e1ccae89a4870338b113e57 [file] [log] [blame]
/*
* Copyright 2018 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 com.android.car.arch.common.LiveDataFunctions.ifThenElse;
import android.car.content.pm.CarPackageManager;
import android.content.Context;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.car.apps.common.util.ViewUtils;
import com.android.car.arch.common.FutureData;
import com.android.car.media.browse.BrowseAdapter;
import com.android.car.media.browse.LimitedBrowseAdapter;
import com.android.car.media.common.GridSpacingItemDecoration;
import com.android.car.media.common.MediaItemMetadata;
import com.android.car.media.common.browse.BrowsedMediaItems;
import com.android.car.media.common.browse.MediaBrowserViewModel;
import com.android.car.media.common.source.MediaSource;
import com.android.car.media.widgets.AppBarController;
import com.android.car.ui.FocusArea;
import com.android.car.ui.baselayout.Insets;
import com.android.car.ui.toolbar.Toolbar;
import com.android.car.uxr.LifeCycleObserverUxrContentLimiter;
import com.android.car.uxr.UxrContentLimiterImpl;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Stack;
/**
* A view controller that implements the content forward browsing experience.
*
* This can be used to display either search or browse results at the root level. Deeper levels will
* be handled the same way between search and browse, using a back stack to return to the root.
*/
public class BrowseViewController extends ViewControllerBase {
private static final String TAG = "BrowseViewController";
private static final String REGULAR_BROWSER_VIEW_MODEL_KEY
= "com.android.car.media.regular_browser_view_model";
private static final String SEARCH_BROWSER_VIEW_MODEL_KEY
= "com.android.car.media.search_browser_view_model";
private final Callbacks mCallbacks;
private final FocusArea mFocusArea;
private final LifeCycleObserverUxrContentLimiter mUxrContentLimiter;
private final RecyclerView mBrowseList;
private final ImageView mErrorIcon;
private final TextView mMessage;
private final LimitedBrowseAdapter mLimitedBrowseAdapter;
private String mSearchQuery;
private final int mFadeDuration;
private final int mLoadingIndicatorDelay;
private final boolean mIsSearchController;
private final boolean mSetFocusAreaHighlightBottom;
private final MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
private final Handler mHandler = new Handler();
/**
* Stores the reference to {@link MediaActivity.ViewModel#getSearchStack} or to
* {@link MediaActivity.ViewModel#getBrowseStack}. Updated in {@link #onMediaSourceChanged}.
*/
private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
private final MediaActivity.ViewModel mViewModel;
private final MediaBrowserViewModel mRootMediaBrowserViewModel;
private final MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
private final BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
@Override
protected void onPlayableItemClicked(MediaItemMetadata item) {
hideKeyboard();
getParent().onPlayableItemClicked(item);
}
@Override
protected void onBrowsableItemClicked(MediaItemMetadata item) {
hideKeyboard();
navigateInto(item);
}
};
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<MediaItemMetadata> mTopItems;
/**
* Callbacks (implemented by the hosting Activity)
*/
public interface Callbacks {
/**
* Method invoked when the user clicks on a playable item
*
* @param item item to be played.
*/
void onPlayableItemClicked(MediaItemMetadata item);
/** Called once the list of the root node's children has been loaded. */
void onRootLoaded();
/** Change to a new UI mode. */
void changeMode(MediaActivity.Mode mode);
FragmentActivity getActivity();
}
/**
* Moves the user one level up in the browse tree. Returns whether that was possible.
*/
private boolean navigateBack() {
boolean result = false;
if (!isAtTopStack()) {
mBrowseStack.pop();
mMediaBrowserViewModel.search(mSearchQuery);
mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
updateAppBar();
result = true;
}
if (isAtTopStack()) {
mShowSearchResults.setValue(mIsSearchController);
}
return result;
}
private void reopenSearch() {
if (mIsSearchController) {
mBrowseStack.clear();
updateAppBar();
mShowSearchResults.setValue(true);
} else {
Log.e(TAG, "reopenSearch called on browse controller");
}
}
@NonNull
private Callbacks getParent() {
return mCallbacks;
}
private FragmentActivity getActivity() {
return mCallbacks.getActivity();
}
/**
* @return whether the user is at the top of the browsing stack.
*/
private boolean isAtTopStack() {
if (mIsSearchController) {
return mBrowseStack.isEmpty();
} else {
// The mBrowseStack stack includes the tab...
return mBrowseStack.size() <= 1;
}
}
/**
* Creates a new instance of this controller meant to browse the root node.
* @return a fully initialized {@link BrowseViewController}
*/
public static BrowseViewController newInstance(Callbacks callbacks,
CarPackageManager carPackageManager, ViewGroup container) {
boolean isSearchController = false;
return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
}
/**
* Creates a new instance of this controller meant to display search results. The root browse
* screen will be the search results for the provided query.
*
* @return a fully initialized {@link BrowseViewController}
*/
static BrowseViewController newSearchInstance(Callbacks callbacks,
CarPackageManager carPackageManager, ViewGroup container) {
boolean isSearchController = true;
return new BrowseViewController(callbacks, carPackageManager, container, isSearchController);
}
private void updateSearchQuery(@Nullable String query) {
mSearchQuery = query;
mMediaBrowserViewModel.search(query);
}
/**
* Clears search state, removes any UI elements from previous results.
*/
@Override
void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
super.onMediaSourceChanged(mediaSource);
mBrowseTreeHasChildren = false;
if (mIsSearchController) {
updateSearchQuery(mViewModel.getSearchQuery());
mAppBarController.setSearchQuery(mSearchQuery);
mBrowseStack = mViewModel.getSearchStack();
mShowSearchResults.setValue(isAtTopStack());
} else {
mBrowseStack = mViewModel.getBrowseStack();
mShowSearchResults.setValue(false);
updateTabs((mediaSource != null) ? null : new ArrayList<>());
}
mLimitedBrowseAdapter.submitItems(null, null);
stopLoadingIndicator();
ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
updateAppBar();
}
private BrowseViewController(Callbacks callbacks, CarPackageManager carPackageManager,
ViewGroup container, boolean isSearchController) {
super(callbacks.getActivity(), carPackageManager, container, R.layout.fragment_browse);
mCallbacks = callbacks;
mIsSearchController = isSearchController;
mLoadingIndicatorDelay = mContent.getContext().getResources()
.getInteger(R.integer.progress_indicator_delay);
mSetFocusAreaHighlightBottom = mContent.getContext().getResources().getBoolean(
R.bool.set_browse_list_focus_area_highlight_above_minimized_control_bar);
mAppBarController.setListener(mAppBarListener);
mFocusArea = mContent.findViewById(R.id.focus_area);
mBrowseList = mContent.findViewById(R.id.browse_list);
mErrorIcon = mContent.findViewById(R.id.error_icon);
mMessage = mContent.findViewById(R.id.error_message);
mFadeDuration = mContent.getContext().getResources().getInteger(
R.integer.new_album_art_fade_in_duration);
FragmentActivity activity = callbacks.getActivity();
mViewModel = ViewModelProviders.of(activity).get(MediaActivity.ViewModel.class);
// Browse logic for the root node
mRootMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(
mMediaSourceVM, ViewModelProviders.of(activity));
mRootMediaBrowserViewModel.getBrowsedMediaItems()
.observe(activity, futureData -> onItemsUpdate(/* forRoot */ true, futureData));
mRootMediaBrowserViewModel.supportsSearch().observe(activity,
mAppBarController::setSearchSupported);
// Browse logic for current node
mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
mIsSearchController ? SEARCH_BROWSER_VIEW_MODEL_KEY : REGULAR_BROWSER_VIEW_MODEL_KEY,
ViewModelProviders.of(activity),
mMediaSourceVM.getConnectedMediaBrowser());
mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
activity.getResources().getDimensionPixelSize(R.dimen.grid_item_spacing)));
GridLayoutManager manager = (GridLayoutManager) mBrowseList.getLayoutManager();
mLimitedBrowseAdapter = new LimitedBrowseAdapter(
new BrowseAdapter(mBrowseList.getContext()), manager, mBrowseAdapterObserver);
mBrowseList.setAdapter(mLimitedBrowseAdapter);
mUxrContentLimiter = new LifeCycleObserverUxrContentLimiter(
new UxrContentLimiterImpl(activity, R.xml.uxr_config));
mUxrContentLimiter.setAdapter(mLimitedBrowseAdapter);
activity.getLifecycle().addObserver(mUxrContentLimiter);
mMediaBrowserViewModel.rootBrowsableHint().observe(activity,
hint -> mLimitedBrowseAdapter.getBrowseAdapter().setRootBrowsableViewType(hint));
mMediaBrowserViewModel.rootPlayableHint().observe(activity,
hint -> mLimitedBrowseAdapter.getBrowseAdapter().setRootPlayableViewType(hint));
LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
mMediaBrowserViewModel.getSearchedMediaItems(),
mMediaBrowserViewModel.getBrowsedMediaItems());
mediaItems.observe(activity, futureData -> onItemsUpdate(/* forRoot */ false, futureData));
updateAppBar();
}
private AppBarController.AppBarListener mAppBarListener = new BasicAppBarListener() {
@Override
public void onTabSelected(MediaItemMetadata item) {
if (mAcceptTabSelection) {
showTopItem(item);
}
}
@Override
public void onSearchSelection() {
if (mIsSearchController) {
reopenSearch();
} else {
mCallbacks.changeMode(MediaActivity.Mode.SEARCHING);
}
}
@Override
public void onSearch(String query) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onSearch: " + query);
}
mViewModel.setSearchQuery(query);
updateSearchQuery(query);
}
};
private Runnable mLoadingIndicatorRunnable = new Runnable() {
@Override
public void run() {
mMessage.setText(R.string.browser_loading);
ViewUtils.showViewAnimated(mMessage, mFadeDuration);
}
};
boolean onBackPressed() {
boolean success = navigateBack();
if (!success && (mIsSearchController)) {
mCallbacks.changeMode(MediaActivity.Mode.BROWSING);
return true;
}
return success;
}
boolean browseTreeHasChildren() {
return mBrowseTreeHasChildren;
}
private void startLoadingIndicator() {
// Display the indicator after a certain time, to avoid flashing the indicator constantly,
// even when performance is acceptable.
mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay);
}
private void stopLoadingIndicator() {
mHandler.removeCallbacks(mLoadingIndicatorRunnable);
ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
}
private void navigateInto(@Nullable MediaItemMetadata item) {
if (item != null) {
mBrowseStack.push(item);
mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
} else {
mMediaBrowserViewModel.setCurrentBrowseId(null);
}
mShowSearchResults.setValue(false);
updateAppBar();
}
/**
* @return the current item being displayed
*/
@Nullable
private MediaItemMetadata getCurrentMediaItem() {
return mBrowseStack.isEmpty() ? null : mBrowseStack.lastElement();
}
@Nullable
private String getCurrentMediaItemId() {
MediaItemMetadata currentItem = getCurrentMediaItem();
return currentItem != null ? currentItem.getId() : null;
}
@Override
public void onCarUiInsetsChanged(@NonNull Insets insets) {
int leftPadding = mBrowseList.getPaddingLeft();
int rightPadding = mBrowseList.getPaddingRight();
int bottomPadding = mBrowseList.getPaddingBottom();
mBrowseList.setPadding(leftPadding, insets.getTop(), rightPadding, bottomPadding);
mFocusArea.setHighlightPadding(leftPadding, insets.getTop(), rightPadding, bottomPadding);
}
void onPlaybackControlsChanged(boolean visible) {
int leftPadding = mBrowseList.getPaddingLeft();
int topPadding = mBrowseList.getPaddingTop();
int rightPadding = mBrowseList.getPaddingRight();
int bottomPadding = visible
? getActivity().getResources().getDimensionPixelOffset(
R.dimen.browse_fragment_bottom_padding)
: 0;
mBrowseList.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
mFocusArea.setHighlightPadding(leftPadding, topPadding, rightPadding,
mSetFocusAreaHighlightBottom ? bottomPadding : 0);
// Set the bottom offset to bottomPadding regardless of mSetFocusAreaHighlightBottom so that
// RotaryService can find the correct target when the user nudges the rotary controller.
mFocusArea.setBoundsOffset(leftPadding, topPadding, rightPadding, bottomPadding);
ViewGroup.MarginLayoutParams messageLayout =
(ViewGroup.MarginLayoutParams) mMessage.getLayoutParams();
messageLayout.bottomMargin = bottomPadding;
mMessage.setLayoutParams(messageLayout);
}
private void hideKeyboard() {
InputMethodManager in =
(InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
in.hideSoftInputFromWindow(mContent.getWindowToken(), 0);
}
private void showTopItem(@Nullable MediaItemMetadata item) {
mViewModel.getBrowseStack().clear();
navigateInto(item);
}
/**
* 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<MediaItemMetadata> 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.
showTopItem(null);
}
updateAppBar();
return;
}
MediaItemMetadata oldTab = mViewModel.getSelectedTab();
try {
mAcceptTabSelection = false;
mAppBarController.setItems(mTopItems.size() == 1 ? null : mTopItems);
updateAppBar();
if (items.contains(oldTab)) {
mAppBarController.setActiveItem(oldTab);
} else {
showTopItem(items.get(0));
}
} finally {
mAcceptTabSelection = true;
}
}
private void updateAppBarTitle() {
boolean isStacked = !isAtTopStack();
final CharSequence title;
if (isStacked) {
// If not at top level, show the current item as title
title = getCurrentMediaItem().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);
}
mAppBarController.setTitle(title);
}
/**
* Update elements of the appbar that change depending on where we are in the browse.
*/
private void updateAppBar() {
boolean isStacked = !isAtTopStack();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "App bar is in stacked state: " + isStacked);
}
Toolbar.State unstackedState =
mIsSearchController ? Toolbar.State.SEARCH : Toolbar.State.HOME;
updateAppBarTitle();
mAppBarController.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
mAppBarController.showSearchIfSupported(!mIsSearchController || isStacked);
}
private String getErrorMessage(boolean forRoot) {
if (forRoot) {
MediaSource mediaSource = mMediaSourceVM.getPrimaryMediaSource().getValue();
return getActivity().getString(
R.string.cannot_connect_to_app,
mediaSource != null
? mediaSource.getDisplayName()
: getActivity().getString(
R.string.unknown_media_provider_name));
} else {
return getActivity().getString(R.string.unknown_error);
}
}
private void onItemsUpdate(boolean forRoot, FutureData<List<MediaItemMetadata>> futureData) {
// Prevent showing loading spinner or any error messages if search is uninitialized
if (mIsSearchController && TextUtils.isEmpty(mSearchQuery)) {
return;
}
if (!forRoot && !mBrowseTreeHasChildren && !mIsSearchController) {
// Ignore live data ghost values
return;
}
if (futureData.isLoading()) {
startLoadingIndicator();
ViewUtils.hideViewAnimated(mErrorIcon, 0);
ViewUtils.hideViewAnimated(mMessage, 0);
// TODO(b/139759881) build a jank-free animation of the transition.
mBrowseList.setAlpha(0f);
mLimitedBrowseAdapter.submitItems(null, null);
if (forRoot) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Loading browse tree...");
}
mBrowseTreeHasChildren = false;
updateTabs(null);
}
return;
}
stopLoadingIndicator();
List<MediaItemMetadata> items =
BrowsedMediaItems.filterItems(forRoot, futureData.getData());
if (forRoot) {
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<>());
} else {
mLimitedBrowseAdapter.submitItems(getCurrentMediaItem(), items);
}
int duration = forRoot ? 0 : mFadeDuration;
if (items == null) {
mMessage.setText(getErrorMessage(forRoot));
ViewUtils.hideViewAnimated(mBrowseList, duration);
ViewUtils.showViewAnimated(mMessage, duration);
ViewUtils.showViewAnimated(mErrorIcon, duration);
} else if (items.isEmpty()) {
mMessage.setText(R.string.nothing_to_play);
ViewUtils.hideViewAnimated(mBrowseList, duration);
ViewUtils.hideViewAnimated(mErrorIcon, duration);
ViewUtils.showViewAnimated(mMessage, duration);
} else if (!forRoot) {
ViewUtils.showViewAnimated(mBrowseList, duration);
ViewUtils.hideViewAnimated(mErrorIcon, duration);
ViewUtils.hideViewAnimated(mMessage, duration);
}
}
}