/* * 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 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>> 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> 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 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> 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 items = MediaBrowserViewModelImpl.filterItems( /*root*/ !mDisplayMediaItems, futureData.getData()); if (mDisplayMediaItems) { mLimitedBrowseAdapter.submitItems(mParentItem, items); List lastNodes = MediaBrowserViewModelImpl.selectBrowseableItems(futureData.getPastData()); Collection 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()); } } }