blob: bc8eb15ca2e4ba7b3c38a77f9d91aa352f75db29 [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.FragmentUtils.checkParent;
import static com.android.car.apps.common.FragmentUtils.requireParent;
import static com.android.car.arch.common.LiveDataFunctions.ifThenElse;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.fragment.app.Fragment;
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.common.GridSpacingItemDecoration;
import com.android.car.media.common.MediaItemMetadata;
import com.android.car.media.common.browse.MediaBrowserViewModel;
import com.android.car.media.common.source.MediaSourceViewModel;
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
/**
* A {@link Fragment} 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 backstack to return to the root.
*/
public class BrowseFragment extends Fragment {
private static final String TAG = "BrowseFragment";
private static final String TOP_MEDIA_ITEM_KEY = "top_media_item";
private static final String SEARCH_KEY = "search_config";
private static final String BROWSE_STACK_KEY = "browse_stack";
private RecyclerView mBrowseList;
private ImageView mErrorIcon;
private TextView mMessage;
private BrowseAdapter mBrowseAdapter;
private MediaItemMetadata mTopMediaItem;
private String mSearchQuery;
private int mFadeDuration;
private int mLoadingIndicatorDelay;
private boolean mIsSearchFragment;
private boolean mPlaybackControlsVisible = false;
// todo(b/130760002): Create new browse fragments at deeper levels.
private MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>();
private Handler mHandler = new Handler();
private Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
private MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel;
private BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() {
@Override
protected void onPlayableItemClicked(MediaItemMetadata item) {
hideKeyboard();
getParent().onPlayableItemClicked(item);
}
@Override
protected void onBrowsableItemClicked(MediaItemMetadata item) {
navigateInto(item);
}
};
/**
* Fragment callbacks (implemented by the hosting Activity)
*/
public interface Callbacks {
/**
* Method invoked when the back stack changes (for example, when the user moves up or down
* the media tree)
*/
void onBackStackChanged();
/**
* Method invoked when the user clicks on a playable item
*
* @param item item to be played.
*/
void onPlayableItemClicked(MediaItemMetadata item);
}
/**
* Moves the user one level up in the browse tree. Returns whether that was possible.
*/
boolean navigateBack() {
boolean result = false;
if (!mBrowseStack.empty()) {
mBrowseStack.pop();
mMediaBrowserViewModel.search(mSearchQuery);
mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
getParent().onBackStackChanged();
adjustBrowseTopPadding();
result = true;
}
if (mBrowseStack.isEmpty()) {
mShowSearchResults.setValue(mIsSearchFragment);
}
return result;
}
@NonNull
private Callbacks getParent() {
return requireParent(this, Callbacks.class);
}
/**
* @return whether the user is at the top of the browsing stack.
*/
public boolean isAtTopStack() {
return mBrowseStack.isEmpty();
}
/**
* Creates a new instance of this fragment. The root browse id will be the one provided to this
* method.
*
* @param item media tree node to display on this fragment.
* @return a fully initialized {@link BrowseFragment}
*/
public static BrowseFragment newInstance(MediaItemMetadata item) {
BrowseFragment fragment = new BrowseFragment();
Bundle args = new Bundle();
args.putParcelable(TOP_MEDIA_ITEM_KEY, item);
fragment.setArguments(args);
return fragment;
}
/**
* Creates a new instance of this fragment, meant to display search results. The root browse
* screen will be the search results for the provided query.
*
* @return a fully initialized {@link BrowseFragment}
*/
public static BrowseFragment newSearchInstance() {
BrowseFragment fragment = new BrowseFragment();
Bundle args = new Bundle();
args.putBoolean(SEARCH_KEY, true);
fragment.setArguments(args);
return fragment;
}
public void updateSearchQuery(@Nullable String query) {
mSearchQuery = query;
mMediaBrowserViewModel.search(query);
}
/**
* Clears search state from this fragment, removes any UI elements from previous results.
*/
public void resetSearchState() {
updateSearchQuery(null);
mBrowseAdapter.submitItems(null, null);
stopLoadingIndicator();
ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle arguments = getArguments();
if (arguments != null) {
mTopMediaItem = arguments.getParcelable(TOP_MEDIA_ITEM_KEY);
mIsSearchFragment = arguments.getBoolean(SEARCH_KEY, false);
mShowSearchResults.setValue(mIsSearchFragment);
}
if (savedInstanceState != null) {
List<MediaItemMetadata> savedStack =
savedInstanceState.getParcelableArrayList(BROWSE_STACK_KEY);
mBrowseStack.clear();
if (savedStack != null) {
mBrowseStack.addAll(savedStack);
}
}
// Get the MediaBrowserViewModel tied to the lifecycle of this fragment, but using the
// MediaSourceViewModel of the activity. This means the media source is consistent across
// all fragments, but the fragment contents themselves will vary
// (e.g. between different browse tabs, search)
mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser(
ViewModelProviders.of(this),
MediaSourceViewModel.get(
requireActivity().getApplication()).getConnectedMediaBrowser());
MediaActivity.ViewModel viewModel = ViewModelProviders.of(requireActivity()).get(
MediaActivity.ViewModel.class);
viewModel.getMiniControlsVisible().observe(this, (visible) -> {
mPlaybackControlsVisible = visible;
adjustBrowseTopPadding();
});
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, final ViewGroup container,
Bundle savedInstanceState) {
int viewId = mIsSearchFragment ? R.layout.fragment_search : R.layout.fragment_browse;
View view = inflater.inflate(viewId, container, false);
mLoadingIndicatorDelay = view.getContext().getResources()
.getInteger(R.integer.progress_indicator_delay);
mBrowseList = view.findViewById(R.id.browse_list);
mErrorIcon = view.findViewById(R.id.error_icon);
mMessage = view.findViewById(R.id.error_message);
mFadeDuration = view.getContext().getResources().getInteger(
R.integer.new_album_art_fade_in_duration);
int numColumns = view.getContext().getResources().getInteger(R.integer.num_browse_columns);
GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), numColumns);
mBrowseList.setLayoutManager(gridLayoutManager);
mBrowseList.addItemDecoration(new GridSpacingItemDecoration(
getResources().getDimensionPixelSize(R.dimen.grid_item_spacing)));
mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext());
mBrowseList.setAdapter(mBrowseAdapter);
mBrowseAdapter.registerObserver(mBrowseAdapterObserver);
if (savedInstanceState == null) {
mMediaBrowserViewModel.search(mSearchQuery);
mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId());
}
mMediaBrowserViewModel.rootBrowsableHint().observe(this, hint ->
mBrowseAdapter.setRootBrowsableViewType(hint));
mMediaBrowserViewModel.rootPlayableHint().observe(this, hint ->
mBrowseAdapter.setRootPlayableViewType(hint));
LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults,
mMediaBrowserViewModel.getSearchedMediaItems(),
mMediaBrowserViewModel.getBrowsedMediaItems());
mediaItems.observe(getViewLifecycleOwner(), futureData ->
{
// Prevent showing loading spinner or any error messages if search is uninitialized
if (mIsSearchFragment && TextUtils.isEmpty(mSearchQuery)) {
return;
}
boolean isLoading = futureData.isLoading();
if (isLoading) {
ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
startLoadingIndicator();
mBrowseAdapter.submitItems(null, null);
return;
}
stopLoadingIndicator();
List<MediaItemMetadata> items = futureData.getData();
mBrowseAdapter.submitItems(getCurrentMediaItem(), items);
if (items == null) {
mMessage.setText(R.string.unknown_error);
ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
ViewUtils.showViewAnimated(mMessage, mFadeDuration);
ViewUtils.showViewAnimated(mErrorIcon, mFadeDuration);
} else if (items.isEmpty()) {
mMessage.setText(R.string.nothing_to_play);
ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration);
ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
ViewUtils.showViewAnimated(mMessage, mFadeDuration);
} else {
ViewUtils.showViewAnimated(mBrowseList, mFadeDuration);
ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration);
ViewUtils.hideViewAnimated(mMessage, mFadeDuration);
}
});
return view;
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
checkParent(this, Callbacks.class);
}
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);
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
ArrayList<MediaItemMetadata> stack = new ArrayList<>(mBrowseStack);
outState.putParcelableArrayList(BROWSE_STACK_KEY, stack);
}
private void navigateInto(MediaItemMetadata item) {
hideKeyboard();
mBrowseStack.push(item);
mShowSearchResults.setValue(false);
mMediaBrowserViewModel.setCurrentBrowseId(item.getId());
getParent().onBackStackChanged();
adjustBrowseTopPadding();
}
/**
* @return the current item being displayed
*/
@Nullable
MediaItemMetadata getCurrentMediaItem() {
if (mBrowseStack.isEmpty()) {
return mTopMediaItem;
} else {
return mBrowseStack.lastElement();
}
}
@Nullable
private String getCurrentMediaItemId() {
MediaItemMetadata currentItem = getCurrentMediaItem();
return currentItem != null ? currentItem.getId() : null;
}
private void adjustBrowseTopPadding() {
if(mBrowseList == null) {
return;
}
int topPadding = isAtTopStack()
? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_top_padding)
: getResources().getDimensionPixelOffset(
R.dimen.browse_fragment_top_padding_stacked);
int bottomPadding = mPlaybackControlsVisible
? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding)
: 0;
mBrowseList.setPadding(mBrowseList.getPaddingLeft(), topPadding,
mBrowseList.getPaddingRight(), bottomPadding);
}
private void hideKeyboard() {
InputMethodManager in =
(InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
in.hideSoftInputFromWindow(getView().getWindowToken(), 0);
}
}