blob: db1cca3faa2fea1b2dba60cb3b3de29d5e2a0f79 [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.apps.common.util.ViewUtils.removeFromParent;
import android.content.res.Resources;
import android.os.Handler;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
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.RecyclerView;
import com.android.car.apps.common.util.FutureData;
import com.android.car.apps.common.util.ViewUtils;
import com.android.car.media.browse.BrowseAdapter;
import com.android.car.media.browse.LimitedBrowseAdapter;
import com.android.car.media.common.MediaItemMetadata;
import com.android.car.media.common.browse.MediaBrowserViewModelImpl;
import com.android.car.media.common.browse.MediaItemsRepository.MediaItemsLiveData;
import com.android.car.media.common.source.MediaSource;
import com.android.car.ui.FocusArea;
import com.android.car.ui.baselayout.Insets;
import com.android.car.ui.recyclerview.CarUiRecyclerView;
import com.android.car.uxr.LifeCycleObserverUxrContentLimiter;
import com.android.car.uxr.UxrContentLimiterImpl;
import java.util.Collection;
import java.util.List;
/**
* A view controller that displays the media item children of a {@link MediaItemMetadata}.
* The controller manages a recycler view where the items can be displayed as a list or a grid, as
* well as an error icon and a message used to indicate loading and errors.
* The content view is initialized with 0 alpha and needs to be animated or set to to full opacity
* to become visible.
*/
public class BrowseViewController {
private static final String TAG = "BrowseViewController";
private final Callbacks mCallbacks;
private final FocusArea mFocusArea;
private final MediaItemMetadata mParentItem;
private final MediaItemsLiveData mMediaItems;
private final boolean mDisplayMediaItems;
private final LifeCycleObserverUxrContentLimiter mUxrContentLimiter;
private final View mContent;
private final CarUiRecyclerView mBrowseList;
private final ImageView mErrorIcon;
private final TextView mMessage;
private final LimitedBrowseAdapter mLimitedBrowseAdapter;
private final int mFadeDuration;
private final int mLoadingIndicatorDelay;
private final boolean mSetFocusAreaHighlightBottom;
private final Handler mHandler = new Handler();
private final MediaActivity.ViewModel mViewModel;
private final BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
@Override
protected void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
mCallbacks.onPlayableItemClicked(item);
}
@Override
protected void onBrowsableItemClicked(@NonNull MediaItemMetadata item) {
mCallbacks.onBrowsableItemClicked(item);
}
};
/**
* The bottom padding of the FocusArea highlight.
*/
private int mFocusAreaHighlightBottomPadding;
/**
* Callbacks (implemented by the host)
*/
public interface Callbacks {
/**
* Method invoked when the user clicks on a playable item
*
* @param item item to be played.
*/
void onPlayableItemClicked(@NonNull MediaItemMetadata item);
/** Invoked when the user clicks on a browsable item. */
void onBrowsableItemClicked(@NonNull MediaItemMetadata item);
/** Invoked when child nodes have been removed from this controller. */
void onChildrenNodesRemoved(@NonNull BrowseViewController controller,
@NonNull Collection<MediaItemMetadata> removedNodes);
FragmentActivity getActivity();
}
private FragmentActivity getActivity() {
return mCallbacks.getActivity();
}
/**
* Creates a controller to display the children of the given parent {@link MediaItemMetadata}.
* This parent node can have been obtained from the browse tree, or from browsing the search
* results.
*/
static BrowseViewController newBrowseController(Callbacks callbacks, ViewGroup container,
@NonNull MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
int rootBrowsableHint, int rootPlayableHint) {
return new BrowseViewController(callbacks, container, parentItem, mediaItems,
rootBrowsableHint, rootPlayableHint, true);
}
/** Creates a controller to display the top results of a search query (in a list). */
static BrowseViewController newSearchResultsController(Callbacks callbacks, ViewGroup container,
MediaItemsLiveData mediaItems) {
return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, true);
}
/**
* Creates a controller to "display" the children of the root: the children are actually hidden
* since they are shown as tabs, and the controller is only used to display loading and error
* messages.
*/
static BrowseViewController newRootController(Callbacks callbacks, ViewGroup container,
MediaItemsLiveData mediaItems) {
return new BrowseViewController(callbacks, container, null, mediaItems, 0, 0, false);
}
private BrowseViewController(Callbacks callbacks, ViewGroup container,
@Nullable MediaItemMetadata parentItem, MediaItemsLiveData mediaItems,
int rootBrowsableHint, int rootPlayableHint, boolean displayMediaItems) {
mCallbacks = callbacks;
mParentItem = parentItem;
mMediaItems = mediaItems;
mDisplayMediaItems = displayMediaItems;
LayoutInflater inflater = LayoutInflater.from(container.getContext());
mContent = inflater.inflate(R.layout.browse_node, container, false);
mContent.setAlpha(0f);
container.addView(mContent);
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);
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);
BrowseAdapter browseAdapter = new BrowseAdapter(mBrowseList.getContext());
mLimitedBrowseAdapter = new LimitedBrowseAdapter(mBrowseList, browseAdapter,
mBrowseAdapterObserver);
mBrowseList.setAdapter(mLimitedBrowseAdapter);
mUxrContentLimiter = new LifeCycleObserverUxrContentLimiter(
new UxrContentLimiterImpl(activity, R.xml.uxr_config));
mUxrContentLimiter.setAdapter(mLimitedBrowseAdapter);
activity.getLifecycle().addObserver(mUxrContentLimiter);
browseAdapter.setRootBrowsableViewType(rootBrowsableHint);
browseAdapter.setRootPlayableViewType(rootPlayableHint);
mMediaItems.observe(activity, mItemsObserver);
}
public MediaItemMetadata getParentItem() {
return mParentItem;
}
/** Shares the browse adapter with the given view... #local-hack. */
public void shareBrowseAdapterWith(RecyclerView view) {
view.setAdapter(mLimitedBrowseAdapter);
}
private final Observer<FutureData<List<MediaItemMetadata>>> mItemsObserver =
this::onItemsUpdate;
View getContent() {
return mContent;
}
String getDebugInfo() {
StringBuilder log = new StringBuilder();
log.append("[");
log.append((mParentItem != null) ? mParentItem.getTitle() : "Root");
log.append("]");
FutureData<List<MediaItemMetadata>> children = mMediaItems.getValue();
if (children == null) {
log.append(" null future data");
} else if (children.isLoading()) {
log.append(" loading");
} else if (children.getData() == null) {
log.append(" null list");
} else {
List<MediaItemMetadata> nodes = children.getData();
log.append(" ");
log.append(nodes.size());
log.append(" {");
if (nodes.size() > 0) {
log.append(nodes.get(0).getTitle().toString());
}
if (nodes.size() > 1) {
log.append(", ");
log.append(nodes.get(1).getTitle().toString());
}
if (nodes.size() > 2) {
log.append(", ...");
}
log.append(" }");
}
return log.toString();
}
void destroy() {
mCallbacks.getActivity().getLifecycle().removeObserver(mUxrContentLimiter);
mMediaItems.removeObserver(mItemsObserver);
removeFromParent(mContent);
}
private Runnable mLoadingIndicatorRunnable = new Runnable() {
@Override
public void run() {
mMessage.setText(R.string.browser_loading);
ViewUtils.showViewAnimated(mMessage, mFadeDuration);
}
};
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);
}
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);
if (bottomPadding > mFocusAreaHighlightBottomPadding) {
mFocusAreaHighlightBottomPadding = bottomPadding;
}
mFocusArea.setHighlightPadding(
leftPadding, insets.getTop(), rightPadding, mFocusAreaHighlightBottomPadding);
mFocusArea.setBoundsOffset(leftPadding, insets.getTop(), rightPadding, bottomPadding);
}
void onPlaybackControlsChanged(boolean visible) {
int leftPadding = mBrowseList.getPaddingLeft();
int topPadding = mBrowseList.getPaddingTop();
int rightPadding = mBrowseList.getPaddingRight();
Resources res = getActivity().getResources();
int bottomPadding = visible
? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding) : 0;
mBrowseList.setPadding(leftPadding, topPadding, rightPadding, bottomPadding);
int highlightBottomPadding = mSetFocusAreaHighlightBottom ? bottomPadding : 0;
if (highlightBottomPadding > mFocusAreaHighlightBottomPadding) {
mFocusAreaHighlightBottomPadding = highlightBottomPadding;
}
mFocusArea.setHighlightPadding(
leftPadding, topPadding, rightPadding, mFocusAreaHighlightBottomPadding);
// 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 String getErrorMessage() {
if (/*root*/ !mDisplayMediaItems) {
MediaSource mediaSource = mViewModel.getMediaSourceValue();
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(@Nullable FutureData<List<MediaItemMetadata>> futureData) {
if (futureData == null || futureData.isLoading()) {
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 (futureData != null) {
startLoadingIndicator();
}
return;
}
stopLoadingIndicator();
List<MediaItemMetadata> items = MediaBrowserViewModelImpl.filterItems(
/*root*/ !mDisplayMediaItems, futureData.getData());
if (mDisplayMediaItems) {
mLimitedBrowseAdapter.submitItems(mParentItem, items);
List<MediaItemMetadata> lastNodes =
MediaBrowserViewModelImpl.selectBrowseableItems(futureData.getPastData());
Collection<MediaItemMetadata> removedNodes =
MediaBrowserViewModelImpl.computeRemovedItems(lastNodes, items);
if (!removedNodes.isEmpty()) {
mCallbacks.onChildrenNodesRemoved(this, removedNodes);
}
}
int duration = mFadeDuration;
if (items == null) {
mMessage.setText(getErrorMessage());
ViewUtils.hideViewAnimated(mBrowseList.getView(), duration);
ViewUtils.showViewAnimated(mMessage, duration);
ViewUtils.showViewAnimated(mErrorIcon, duration);
} else if (items.isEmpty()) {
mMessage.setText(R.string.nothing_to_play);
ViewUtils.hideViewAnimated(mBrowseList.getView(), duration);
ViewUtils.hideViewAnimated(mErrorIcon, duration);
ViewUtils.showViewAnimated(mMessage, duration);
} else {
ViewUtils.showViewAnimated(mBrowseList.getView(), duration);
ViewUtils.hideViewAnimated(mErrorIcon, duration);
ViewUtils.hideViewAnimated(mMessage, duration);
}
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "onItemsUpdate " + getDebugInfo());
}
}
}