| /* |
| * 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()); |
| } |
| } |
| } |