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.arch.common.LiveDataFunctions.ifThenElse; 20 21 import android.annotation.NonNull; 22 import android.annotation.Nullable; 23 import android.content.Context; 24 import android.content.res.Resources; 25 import android.os.Handler; 26 import android.text.TextUtils; 27 import android.util.Log; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.inputmethod.InputMethodManager; 32 import android.widget.ImageView; 33 import android.widget.TextView; 34 35 import androidx.fragment.app.FragmentActivity; 36 import androidx.lifecycle.LiveData; 37 import androidx.lifecycle.MutableLiveData; 38 import androidx.lifecycle.ViewModelProviders; 39 import androidx.recyclerview.widget.RecyclerView; 40 41 import com.android.car.apps.common.util.ViewUtils; 42 import com.android.car.arch.common.FutureData; 43 import com.android.car.media.browse.BrowseAdapter; 44 import com.android.car.media.common.GridSpacingItemDecoration; 45 import com.android.car.media.common.MediaItemMetadata; 46 import com.android.car.media.common.browse.MediaBrowserViewModel; 47 import com.android.car.media.common.source.MediaSource; 48 import com.android.car.media.common.source.MediaSourceViewModel; 49 50 import java.util.List; 51 import java.util.Stack; 52 import java.util.stream.Collectors; 53 54 /** 55 * A view "controller" that implements the content forward browsing experience. 56 * 57 * This can be used to display either search or browse results at the root level. Deeper levels will 58 * be handled the same way between search and browse, using a back stack to return to the root. 59 */ 60 public class BrowseFragment { 61 private static final String TAG = "BrowseFragment"; 62 63 private static final String REGULAR_BROWSER_VIEW_MODEL_KEY 64 = "com.android.car.media.regular_browser_view_model"; 65 private static final String SEARCH_BROWSER_VIEW_MODEL_KEY 66 = "com.android.car.media.search_browser_view_model"; 67 68 private final Callbacks mCallbacks; 69 private final View mFragmentContent; 70 71 private RecyclerView mBrowseList; 72 private ViewGroup mBrowseState; 73 private ImageView mErrorIcon; 74 private TextView mMessage; 75 private BrowseAdapter mBrowseAdapter; 76 private String mSearchQuery; 77 private int mFadeDuration; 78 private int mLoadingIndicatorDelay; 79 private boolean mIsSearchFragment; 80 // todo(b/130760002): Create new browse fragments at deeper levels. 81 private MutableLiveData<Boolean> mShowSearchResults = new MutableLiveData<>(); 82 private Handler mHandler = new Handler(); 83 private Stack<MediaItemMetadata> mBrowseStack = new Stack<>(); 84 private MediaBrowserViewModel.WithMutableBrowseId mMediaBrowserViewModel; 85 private BrowseAdapter.Observer mBrowseAdapterObserver = new BrowseAdapter.Observer() { 86 87 @Override 88 protected void onPlayableItemClicked(MediaItemMetadata item) { 89 hideKeyboard(); 90 getParent().onPlayableItemClicked(item); 91 } 92 93 @Override 94 protected void onBrowsableItemClicked(MediaItemMetadata item) { 95 hideKeyboard(); 96 navigateInto(item); 97 } 98 }; 99 100 /** 101 * Fragment callbacks (implemented by the hosting Activity) 102 */ 103 public interface Callbacks { 104 /** 105 * Method invoked when the back stack changes (for example, when the user moves up or down 106 * the media tree) 107 */ onBackStackChanged()108 void onBackStackChanged(); 109 110 /** 111 * Method invoked when the user clicks on a playable item 112 * 113 * @param item item to be played. 114 */ onPlayableItemClicked(MediaItemMetadata item)115 void onPlayableItemClicked(MediaItemMetadata item); 116 getActivity()117 FragmentActivity getActivity(); 118 } 119 120 /** 121 * Moves the user one level up in the browse tree. Returns whether that was possible. 122 */ navigateBack()123 boolean navigateBack() { 124 boolean result = false; 125 if (!isAtTopStack()) { 126 mBrowseStack.pop(); 127 mMediaBrowserViewModel.search(mSearchQuery); 128 mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId()); 129 getParent().onBackStackChanged(); 130 result = true; 131 } 132 if (isAtTopStack()) { 133 mShowSearchResults.setValue(mIsSearchFragment); 134 } 135 return result; 136 } 137 reopenSearch()138 void reopenSearch() { 139 if (mIsSearchFragment) { 140 mBrowseStack.clear(); 141 getParent().onBackStackChanged(); 142 mShowSearchResults.setValue(true); 143 } else { 144 Log.e(TAG, "reopenSearch called on browse fragment"); 145 } 146 } 147 148 @NonNull getParent()149 private Callbacks getParent() { 150 return mCallbacks; 151 } 152 getActivity()153 private FragmentActivity getActivity() { 154 return mCallbacks.getActivity(); 155 } 156 157 /** 158 * @return whether the user is at the top of the browsing stack. 159 */ isAtTopStack()160 boolean isAtTopStack() { 161 if (mIsSearchFragment) { 162 return mBrowseStack.isEmpty(); 163 } else { 164 // The mBrowseStack stack includes the tab... 165 return mBrowseStack.size() <= 1; 166 } 167 } 168 169 /** 170 * Creates a new instance of this fragment. The root browse id will be the one provided to this 171 * method. 172 * @return a fully initialized {@link BrowseFragment} 173 */ newInstance(Callbacks callbacks, ViewGroup container)174 public static BrowseFragment newInstance(Callbacks callbacks, ViewGroup container) { 175 boolean isSearchFragment = false; 176 return new BrowseFragment(callbacks, container, isSearchFragment); 177 } 178 179 /** 180 * Creates a new instance of this fragment, meant to display search results. The root browse 181 * screen will be the search results for the provided query. 182 * 183 * @return a fully initialized {@link BrowseFragment} 184 */ newSearchInstance(Callbacks callbacks, ViewGroup container)185 static BrowseFragment newSearchInstance(Callbacks callbacks, ViewGroup container) { 186 boolean isSearchFragment = true; 187 return new BrowseFragment(callbacks, container, isSearchFragment); 188 } 189 updateSearchQuery(@ullable String query)190 void updateSearchQuery(@Nullable String query) { 191 mSearchQuery = query; 192 mMediaBrowserViewModel.search(query); 193 } 194 195 /** 196 * Clears search state from this fragment, removes any UI elements from previous results. 197 */ resetState()198 void resetState() { 199 MediaActivity.ViewModel viewModel = ViewModelProviders.of(mCallbacks.getActivity()).get( 200 MediaActivity.ViewModel.class); 201 202 if (mIsSearchFragment) { 203 updateSearchQuery(viewModel.getSearchQuery()); 204 mShowSearchResults.setValue(false); 205 mBrowseStack = viewModel.getSearchStack(); 206 mShowSearchResults.setValue(isAtTopStack()); 207 } else { 208 mBrowseStack = viewModel.getBrowseStack(); 209 mShowSearchResults.setValue(false); 210 } 211 212 mBrowseAdapter.submitItems(null, null); 213 stopLoadingIndicator(); 214 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 215 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 216 217 mMediaBrowserViewModel.setCurrentBrowseId(getCurrentMediaItemId()); 218 } 219 BrowseFragment(Callbacks callbacks, ViewGroup container, boolean isSearchFragment)220 private BrowseFragment(Callbacks callbacks, ViewGroup container, boolean isSearchFragment) { 221 mCallbacks = callbacks; 222 223 FragmentActivity activity = callbacks.getActivity(); 224 225 mIsSearchFragment = isSearchFragment; 226 mMediaBrowserViewModel = MediaBrowserViewModel.Factory.getInstanceWithMediaBrowser( 227 mIsSearchFragment ? SEARCH_BROWSER_VIEW_MODEL_KEY : REGULAR_BROWSER_VIEW_MODEL_KEY, 228 ViewModelProviders.of(activity), 229 MediaSourceViewModel.get(activity.getApplication()).getConnectedMediaBrowser()); 230 231 int viewId = mIsSearchFragment ? R.layout.fragment_search : R.layout.fragment_browse; 232 mFragmentContent = 233 LayoutInflater.from(container.getContext()).inflate(viewId, container, false); 234 mLoadingIndicatorDelay = mFragmentContent.getContext().getResources() 235 .getInteger(R.integer.progress_indicator_delay); 236 mBrowseList = mFragmentContent.findViewById(R.id.browse_list); 237 mBrowseState = mFragmentContent.findViewById(R.id.browse_state); 238 mErrorIcon = mFragmentContent.findViewById(R.id.error_icon); 239 mMessage = mFragmentContent.findViewById(R.id.error_message); 240 mFadeDuration = mFragmentContent.getContext().getResources().getInteger( 241 R.integer.new_album_art_fade_in_duration); 242 243 mBrowseList.addItemDecoration(new GridSpacingItemDecoration( 244 activity.getResources().getDimensionPixelSize(R.dimen.grid_item_spacing))); 245 246 mBrowseAdapter = new BrowseAdapter(mBrowseList.getContext()); 247 mBrowseList.setAdapter(mBrowseAdapter); 248 mBrowseAdapter.registerObserver(mBrowseAdapterObserver); 249 250 mMediaBrowserViewModel.rootBrowsableHint().observe(activity, hint -> 251 mBrowseAdapter.setRootBrowsableViewType(hint)); 252 mMediaBrowserViewModel.rootPlayableHint().observe(activity, hint -> 253 mBrowseAdapter.setRootPlayableViewType(hint)); 254 LiveData<FutureData<List<MediaItemMetadata>>> mediaItems = ifThenElse(mShowSearchResults, 255 mMediaBrowserViewModel.getSearchedMediaItems(), 256 mMediaBrowserViewModel.getBrowsedMediaItems()); 257 258 // TODO(b/145688665) merge with #update 259 mediaItems.observe(activity, futureData -> 260 { 261 // Prevent showing loading spinner or any error messages if search is uninitialized 262 if (mIsSearchFragment && TextUtils.isEmpty(mSearchQuery)) { 263 return; 264 } 265 boolean isLoading = futureData.isLoading(); 266 if (isLoading) { 267 // TODO(b/139759881) build a jank-free animation of the transition. 268 mBrowseList.setAlpha(0f); 269 startLoadingIndicator(); 270 mBrowseAdapter.submitItems(null, null); 271 return; 272 } 273 stopLoadingIndicator(); 274 List<MediaItemMetadata> items = futureData.getData(); 275 if (items != null) { 276 items = items.stream().filter(item -> 277 (item.isPlayable() || item.isBrowsable())).collect(Collectors.toList()); 278 } 279 mBrowseAdapter.submitItems(getCurrentMediaItem(), items); 280 if (items == null) { 281 mMessage.setText(R.string.unknown_error); 282 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration); 283 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 284 ViewUtils.showViewAnimated(mErrorIcon, mFadeDuration); 285 } else if (items.isEmpty()) { 286 mMessage.setText(R.string.nothing_to_play); 287 ViewUtils.hideViewAnimated(mBrowseList, mFadeDuration); 288 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 289 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 290 } else { 291 ViewUtils.showViewAnimated(mBrowseList, mFadeDuration); 292 ViewUtils.hideViewAnimated(mErrorIcon, mFadeDuration); 293 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 294 } 295 }); 296 297 container.addView(mFragmentContent); 298 } 299 300 private Runnable mLoadingIndicatorRunnable = new Runnable() { 301 @Override 302 public void run() { 303 mMessage.setText(R.string.browser_loading); 304 ViewUtils.showViewAnimated(mMessage, mFadeDuration); 305 } 306 }; 307 startLoadingIndicator()308 private void startLoadingIndicator() { 309 // Display the indicator after a certain time, to avoid flashing the indicator constantly, 310 // even when performance is acceptable. 311 mHandler.postDelayed(mLoadingIndicatorRunnable, mLoadingIndicatorDelay); 312 } 313 stopLoadingIndicator()314 private void stopLoadingIndicator() { 315 mHandler.removeCallbacks(mLoadingIndicatorRunnable); 316 ViewUtils.hideViewAnimated(mMessage, mFadeDuration); 317 } 318 navigateInto(@ullable MediaItemMetadata item)319 void navigateInto(@Nullable MediaItemMetadata item) { 320 if (item != null) { 321 mBrowseStack.push(item); 322 mMediaBrowserViewModel.setCurrentBrowseId(item.getId()); 323 } else { 324 mMediaBrowserViewModel.setCurrentBrowseId(null); 325 } 326 327 mShowSearchResults.setValue(false); 328 getParent().onBackStackChanged(); 329 } 330 331 /** 332 * @return the current item being displayed 333 */ 334 @Nullable getCurrentMediaItem()335 MediaItemMetadata getCurrentMediaItem() { 336 return mBrowseStack.isEmpty() ? null : mBrowseStack.lastElement(); 337 } 338 339 @Nullable getCurrentMediaItemId()340 private String getCurrentMediaItemId() { 341 MediaItemMetadata currentItem = getCurrentMediaItem(); 342 return currentItem != null ? currentItem.getId() : null; 343 } 344 onAppBarHeightChanged(int height)345 public void onAppBarHeightChanged(int height) { 346 if (mBrowseList == null) { 347 return; 348 } 349 350 mBrowseList.setPadding(mBrowseList.getPaddingLeft(), height, 351 mBrowseList.getPaddingRight(), mBrowseList.getPaddingBottom()); 352 } 353 onPlaybackControlsChanged(boolean visible, int browseStateTopMargin, int browseStateBottomMargin)354 void onPlaybackControlsChanged(boolean visible, int browseStateTopMargin, 355 int browseStateBottomMargin) { 356 ViewGroup.MarginLayoutParams params = 357 (ViewGroup.MarginLayoutParams) mBrowseState.getLayoutParams(); 358 params.topMargin = browseStateTopMargin; 359 params.bottomMargin = browseStateBottomMargin; 360 361 if (mBrowseList == null) { 362 return; 363 } 364 365 Resources res = getActivity().getResources(); 366 int bottomPadding = visible 367 ? res.getDimensionPixelOffset(R.dimen.browse_fragment_bottom_padding) 368 : 0; 369 mBrowseList.setPadding(mBrowseList.getPaddingLeft(), mBrowseList.getPaddingTop(), 370 mBrowseList.getPaddingRight(), bottomPadding); 371 } 372 hideKeyboard()373 private void hideKeyboard() { 374 InputMethodManager in = 375 (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); 376 in.hideSoftInputFromWindow(mFragmentContent.getWindowToken(), 0); 377 } 378 379 /** 380 * Updates the state of this fragment. 381 */ setRootState(@onNull MediaBrowserViewModel.BrowseState state, @Nullable MediaSource mediaSource)382 void setRootState(@NonNull MediaBrowserViewModel.BrowseState state, 383 @Nullable MediaSource mediaSource) { 384 mHandler.removeCallbacks(mLoadingIndicatorRunnable); 385 update(state, mediaSource); 386 } 387 update(@onNull MediaBrowserViewModel.BrowseState state, @Nullable MediaSource mediaSource)388 private void update(@NonNull MediaBrowserViewModel.BrowseState state, 389 @Nullable MediaSource mediaSource) { 390 switch (state) { 391 case LOADING: 392 // Display the indicator after a certain time, to avoid flashing the indicator 393 // constantly, even when performance is acceptable. 394 startLoadingIndicator(); 395 ViewUtils.hideViewAnimated(mErrorIcon, 0); 396 ViewUtils.hideViewAnimated(mMessage, 0); 397 break; 398 case ERROR: 399 ViewUtils.showViewAnimated(mErrorIcon, 0); 400 ViewUtils.showViewAnimated(mMessage, 0); 401 mMessage.setText(getActivity().getString( 402 R.string.cannot_connect_to_app, 403 mediaSource != null 404 ? mediaSource.getDisplayName() 405 : getActivity().getString( 406 R.string.unknown_media_provider_name))); 407 break; 408 case EMPTY: 409 ViewUtils.hideViewAnimated(mErrorIcon, 0); 410 ViewUtils.showViewAnimated(mMessage, 0); 411 mMessage.setText(getActivity().getString(R.string.nothing_to_play)); 412 break; 413 case LOADED: 414 Log.d(TAG, "Updated with LOADED state, ignoring."); 415 // Do nothing. 416 break; 417 default: 418 // Fail fast on any other state. 419 throw new IllegalStateException("Invalid state for this fragment: " + state); 420 } 421 } 422 } 423