1 /* 2 * Copyright 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.car.media; 18 19 import static com.android.car.apps.common.FragmentUtils.checkParent; 20 import static com.android.car.apps.common.FragmentUtils.requireParent; 21 import static com.android.car.arch.common.LiveDataFunctions.ifThenElse; 22 23 import android.annotation.NonNull; 24 import android.annotation.Nullable; 25 import android.content.Context; 26 import android.os.Bundle; 27 import android.os.Handler; 28 import android.text.TextUtils; 29 import android.view.LayoutInflater; 30 import android.view.View; 31 import android.view.ViewGroup; 32 import android.view.inputmethod.InputMethodManager; 33 import android.widget.ImageView; 34 import android.widget.TextView; 35 36 import androidx.fragment.app.Fragment; 37 import androidx.lifecycle.LiveData; 38 import androidx.lifecycle.MutableLiveData; 39 import androidx.lifecycle.ViewModelProviders; 40 import androidx.recyclerview.widget.GridLayoutManager; 41 import androidx.recyclerview.widget.RecyclerView; 42 43 import com.android.car.apps.common.util.ViewUtils; 44 import com.android.car.arch.common.FutureData; 45 import com.android.car.media.browse.BrowseAdapter; 46 import com.android.car.media.common.GridSpacingItemDecoration; 47 import com.android.car.media.common.MediaItemMetadata; 48 import com.android.car.media.common.browse.MediaBrowserViewModel; 49 import com.android.car.media.common.source.MediaSourceViewModel; 50 51 import java.util.ArrayList; 52 import java.util.List; 53 import java.util.Stack; 54 55 /** 56 * A {@link Fragment} that implements the content forward browsing experience. 57 * 58 * This can be used to display either search or browse results at the root level. Deeper levels will 59 * be handled the same way between search and browse, using a backstack to return to the root. 60 */ 61 public class BrowseFragment extends Fragment { 62 private static final String TAG = "BrowseFragment"; 63 private static final String TOP_MEDIA_ITEM_KEY = "top_media_item"; 64 private static final String SEARCH_KEY = "search_config"; 65 private static final String BROWSE_STACK_KEY = "browse_stack"; 66 67 private RecyclerView mBrowseList; 68 private ImageView mErrorIcon; 69 private TextView mMessage; 70 private BrowseAdapter mBrowseAdapter; 71 private MediaItemMetadata mTopMediaItem; 72 private String mSearchQuery; 73 private int mFadeDuration; 74 private int mLoadingIndicatorDelay; 75 private boolean mIsSearchFragment; 76 private boolean mPlaybackControlsVisible = false; 77 // todo(b/130760002): Create new browse fragments at deeper levels. 78 private MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>(); 79 private Handler mHandler = new Handler(); 80 private Stack<MediaItemMetadata> mBrowseStack = new Stack<>(); 81 private MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel; 82 private BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() { 83 84 @Override 85 protected void onPlayableItemClicked(MediaItemMetadata item) { 86 hideKeyboard(); 87 getParent().onPlayableItemClicked(item); 88 } 89 90 @Override 91 protected void onBrowsableItemClicked(MediaItemMetadata item) { 92 navigateInto(item); 93 } 94 }; 95 96 /** 97 * Fragment callbacks (implemented by the hosting Activity) 98 */ 99 public interface Callbacks { 100 /** 101 * Method invoked when the back stack changes (for example, when the user moves up or down 102 * the media tree) 103 */ onBackStackChanged()104 void onBackStackChanged(); 105 106 /** 107 * Method invoked when the user clicks on a playable item 108 * 109 * @param item item to be played. 110 */ onPlayableItemClicked(MediaItemMetadata item)111 void onPlayableItemClicked(MediaItemMetadata item); 112 } 113 114 /** 115 * Moves the user one level up in the browse tree. Returns whether that was possible. 116 */ navigateBack()117 boolean navigateBack() { 118 boolean result = false; 119 if (!mBrowseStack.empty()) { 120 mBrowseStack.pop(); 121 mMediaBrowserViewModel.search(mSearchQuery); 122 mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId()); 123 getParent().onBackStackChanged(); 124 adjustBrowseTopPadding(); 125 result = true; 126 } 127 if (mBrowseStack.isEmpty()) { 128 mShowSearchResults.setValue(mIsSearchFragment); 129 } 130 return result; 131 } 132 133 @NonNull getParent()134 private Callbacks getParent() { 135 return requireParent(this, Callbacks.class); 136 } 137 138 /** 139 * @return whether the user is at the top of the browsing stack. 140 */ isAtTopStack()141 public boolean isAtTopStack() { 142 return mBrowseStack.isEmpty(); 143 } 144 145 /** 146 * Creates a new instance of this fragment. The root browse id will be the one provided to this 147 * method. 148 * 149 * @param item media tree node to display on this fragment. 150 * @return a fully initialized {@link BrowseFragment} 151 */ newInstance(MediaItemMetadata item)152 public static BrowseFragment newInstance(MediaItemMetadata item) { 153 BrowseFragment fragment = new BrowseFragment(); 154 Bundle args = new Bundle(); 155 args.putParcelable(TOP_MEDIA_ITEM_KEY, item); 156 fragment.setArguments(args); 157 return fragment; 158 } 159 160 /** 161 * Creates a new instance of this fragment, meant to display search results. The root browse 162 * screen will be the search results for the provided query. 163 * 164 * @return a fully initialized {@link BrowseFragment} 165 */ newSearchInstance()166 public static BrowseFragment newSearchInstance() { 167 BrowseFragment fragment = new BrowseFragment(); 168 Bundle args = new Bundle(); 169 args.putBoolean(SEARCH_KEY, true); 170 fragment.setArguments(args); 171 return fragment; 172 } 173 updateSearchQuery(@ullable String query)174 public void updateSearchQuery(@Nullable String query) { 175 mSearchQuery = query; 176 mMediaBrowserViewModel.search(query); 177 } 178 179 /** 180 * Clears search state from this fragment, removes any UI elements from previous results. 181 */ resetSearchState()182 public void resetSearchState() { 183 updateSearchQuery(null); 184 mBrowseAdapter.submitItems(null, null); 185 stopLoadingIndicator(); 186 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 187 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 188 } 189 190 @Override onCreate(@ullable Bundle savedInstanceState)191 public void onCreate(@Nullable Bundle savedInstanceState) { 192 super.onCreate(savedInstanceState); 193 Bundle arguments = getArguments(); 194 if (arguments != null) { 195 mTopMediaItem = arguments.getParcelable(TOP_MEDIA_ITEM_KEY); 196 mIsSearchFragment = arguments.getBoolean(SEARCH_KEY, false); 197 mShowSearchResults.setValue(mIsSearchFragment); 198 } 199 if (savedInstanceState != null) { 200 List<MediaItemMetadata> savedStack = 201 savedInstanceState.getParcelableArrayList(BROWSE_STACK_KEY); 202 mBrowseStack.clear(); 203 if (savedStack != null) { 204 mBrowseStack.addAll(savedStack); 205 } 206 } 207 208 // Get the MediaBrowserViewModel tied to the lifecycle of this fragment, but using the 209 // MediaSourceViewModel of the activity. This means the media source is consistent across 210 // all fragments, but the fragment contents themselves will vary 211 // (e.g. between different browse tabs, search) 212 mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser( 213 ViewModelProviders.of(this), 214 MediaSourceViewModel.get( 215 requireActivity().getApplication()).getConnectedMediaBrowser()); 216 217 MediaActivity.ViewModel viewModel = ViewModelProviders.of(requireActivity()).get( 218 MediaActivity.ViewModel.class); 219 viewModel.getMiniControlsVisible().observe(this, (visible) -> { 220 mPlaybackControlsVisible = visible; 221 adjustBrowseTopPadding(); 222 }); 223 224 } 225 226 @Override onCreateView(@onNull LayoutInflater inflater, final ViewGroup container, Bundle savedInstanceState)227 public View onCreateView(@NonNull LayoutInflater inflater, final ViewGroup container, 228 Bundle savedInstanceState) { 229 int viewId = mIsSearchFragment ? R.layout.fragment_search : R.layout.fragment_browse; 230 View view = inflater.inflate(viewId, container, false); 231 mLoadingIndicatorDelay = view.getContext().getResources() 232 .getInteger(R.integer.progress_indicator_delay); 233 mBrowseList = view.findViewById(R.id.browse_list); 234 mErrorIcon = view.findViewById(R.id.error_icon); 235 mMessage = view.findViewById(R.id.error_message); 236 mFadeDuration = view.getContext().getResources().getInteger( 237 R.integer.new_album_art_fade_in_duration); 238 int numColumns = view.getContext().getResources().getInteger(R.integer.num_browse_columns); 239 GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), numColumns); 240 241 mBrowseList.setLayoutManager(gridLayoutManager); 242 mBrowseList.addItemDecoration(new GridSpacingItemDecoration( 243 getResources().getDimensionPixelSize(R.dimen.grid_item_spacing), 244 getResources().getDimensionPixelSize(R.dimen.grid_item_margin_x), 245 getResources().getDimensionPixelSize(R.dimen.grid_item_margin_x) 246 )); 247 248 mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext()); 249 mBrowseList.setAdapter(mBrowseAdapter); 250 mBrowseAdapter.registerObserver(mBrowseAdapterObserver); 251 252 if (savedInstanceState == null) { 253 mMediaBrowserViewModel.search(mSearchQuery); 254 mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId()); 255 } 256 mMediaBrowserViewModel.rootBrowsableHint().observe(this, hint -> 257 mBrowseAdapter.setRootBrowsableViewType(hint)); 258 mMediaBrowserViewModel.rootPlayableHint().observe(this, hint -> 259 mBrowseAdapter.setRootPlayableViewType(hint)); 260 LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults, 261 mMediaBrowserViewModel.getSearchedMediaItems(), 262 mMediaBrowserViewModel.getBrowsedMediaItems()); 263 264 mediaItems.observe(getViewLifecycleOwner(), futureData -> 265 { 266 // Prevent showing loading spinner or any error messages if search is uninitialized 267 if (mIsSearchFragment && TextUtils.isEmpty(mSearchQuery)) { 268 return; 269 } 270 boolean isLoading = futureData.isLoading(); 271 if (isLoading) { 272 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration); 273 startLoadingIndicator(); 274 return; 275 } 276 stopLoadingIndicator(); 277 List<MediaItemMetadata> items = futureData.getData(); 278 mBrowseAdapter.submitItems(getCurrentMediaItem(), items); 279 if (items == null) { 280 mMessage.setText(R.string.unknown_error); 281 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration); 282 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 283 ViewUtils.showViewAnimated(mErrorIcon, mFadeDuration); 284 } else if (items.isEmpty()) { 285 mMessage.setText(R.string.nothing_to_play); 286 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration); 287 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 288 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 289 } else { 290 ViewUtils.showViewAnimated(mBrowseList, mFadeDuration); 291 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 292 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 293 } 294 }); 295 return view; 296 } 297 298 @Override onAttach(Context context)299 public void onAttach(Context context) { 300 super.onAttach(context); 301 checkParent(this, Callbacks.class); 302 } 303 304 private Runnable mLoadingIndicatorRunnable = new Runnable() { 305 @Override 306 public void run() { 307 mMessage.setText(R.string.browser_loading); 308 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 309 } 310 }; 311 startLoadingIndicator()312 private void startLoadingIndicator() { 313 // Display the indicator after a certain time, to avoid flashing the indicator constantly, 314 // even when performance is acceptable. 315 mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay); 316 } 317 stopLoadingIndicator()318 private void stopLoadingIndicator() { 319 mHandler.removeCallbacks(mLoadingIndicatorRunnable); 320 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 321 } 322 323 @Override onSaveInstanceState(@onNull Bundle outState)324 public void onSaveInstanceState(@NonNull Bundle outState) { 325 super.onSaveInstanceState(outState); 326 ArrayList<MediaItemMetadata> stack = new ArrayList<>(mBrowseStack); 327 outState.putParcelableArrayList(BROWSE_STACK_KEY, stack); 328 } 329 navigateInto(MediaItemMetadata item)330 private void navigateInto(MediaItemMetadata item) { 331 hideKeyboard(); 332 mBrowseStack.push(item); 333 mShowSearchResults.setValue(false); 334 mMediaBrowserViewModel.setCurrentBrowseId(item.getId()); 335 getParent().onBackStackChanged(); 336 adjustBrowseTopPadding(); 337 } 338 339 /** 340 * @return the current item being displayed 341 */ 342 @Nullable getCurrentMediaItem()343 MediaItemMetadata getCurrentMediaItem() { 344 if (mBrowseStack.isEmpty()) { 345 return mTopMediaItem; 346 } else { 347 return mBrowseStack.lastElement(); 348 } 349 } 350 351 @Nullable getCurrentMediaItemId()352 private String getCurrentMediaItemId() { 353 MediaItemMetadata currentItem = getCurrentMediaItem(); 354 return currentItem != null ? currentItem.getId() : null; 355 } 356 adjustBrowseTopPadding()357 private void adjustBrowseTopPadding() { 358 if(mBrowseList == null) { 359 return; 360 } 361 362 int topPadding = isAtTopStack() 363 ? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_top_padding) 364 : getResources().getDimensionPixelOffset( 365 R.dimen.browse_fragment_top_padding_stacked); 366 int bottomPadding = mPlaybackControlsVisible 367 ? getResources().getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding) 368 : 0; 369 370 mBrowseList.setPadding(mBrowseList.getPaddingLeft(), topPadding, 371 mBrowseList.getPaddingRight(), bottomPadding); 372 } 373 hideKeyboard()374 private void hideKeyboard() { 375 InputMethodManager in = 376 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); 377 in.hideSoftInputFromWindow(getView().getWindowToken(), 0); 378 } 379 } 380