1 /* 2 * Copyright (C) 2016 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 package com.android.car.media; 17 18 import android.annotation.SuppressLint; 19 import android.app.AlertDialog; 20 import android.app.Application; 21 import android.app.PendingIntent; 22 import android.car.Car; 23 import android.car.drivingstate.CarUxRestrictions; 24 import android.content.ActivityNotFoundException; 25 import android.content.ComponentName; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.ResolveInfo; 29 import android.graphics.drawable.BitmapDrawable; 30 import android.graphics.drawable.Drawable; 31 import android.media.audiofx.AudioEffect; 32 import android.os.Bundle; 33 import android.support.v4.media.session.PlaybackStateCompat; 34 import android.text.TextUtils; 35 import android.util.Log; 36 import android.util.Size; 37 import android.view.GestureDetector; 38 import android.view.MotionEvent; 39 import android.view.View; 40 import android.view.ViewConfiguration; 41 import android.view.ViewGroup; 42 import android.widget.Toast; 43 44 import androidx.annotation.NonNull; 45 import androidx.annotation.Nullable; 46 import androidx.core.view.GestureDetectorCompat; 47 import androidx.fragment.app.Fragment; 48 import androidx.fragment.app.FragmentActivity; 49 import androidx.lifecycle.AndroidViewModel; 50 import androidx.lifecycle.LiveData; 51 import androidx.lifecycle.MutableLiveData; 52 import androidx.lifecycle.ViewModelProviders; 53 54 import com.android.car.apps.common.CarUxRestrictionsUtil; 55 import com.android.car.apps.common.util.CarPackageManagerUtils; 56 import com.android.car.apps.common.util.ViewUtils; 57 import com.android.car.media.common.MediaConstants; 58 import com.android.car.media.common.MediaItemMetadata; 59 import com.android.car.media.common.MinimizedPlaybackControlBar; 60 import com.android.car.media.common.browse.MediaBrowserViewModel; 61 import com.android.car.media.common.playback.PlaybackViewModel; 62 import com.android.car.media.common.source.MediaSource; 63 import com.android.car.media.common.source.MediaSourceViewModel; 64 import com.android.car.media.widgets.AppBarView; 65 import com.android.car.ui.toolbar.Toolbar; 66 import com.android.car.ui.AlertDialogBuilder; 67 68 import java.util.ArrayList; 69 import java.util.Collections; 70 import java.util.HashMap; 71 import java.util.List; 72 import java.util.Map; 73 import java.util.Objects; 74 import java.util.Stack; 75 import java.util.stream.Collectors; 76 77 /** 78 * This activity controls the UI of media. It also updates the connection status for the media app 79 * by broadcast. 80 */ 81 public class MediaActivity extends FragmentActivity implements BrowseFragment.Callbacks { 82 private static final String TAG = "MediaActivity"; 83 84 /** Configuration (controlled from resources) */ 85 private int mFadeDuration; 86 87 /** Models */ 88 private PlaybackViewModel.PlaybackController mPlaybackController; 89 90 /** Layout views */ 91 private View mRootView; 92 private AppBarView mAppBarView; 93 private PlaybackFragment mPlaybackFragment; 94 private BrowseFragment mSearchFragment; 95 private BrowseFragment mBrowseFragment; 96 private MinimizedPlaybackControlBar mMiniPlaybackControls; 97 private ViewGroup mBrowseContainer; 98 private ViewGroup mPlaybackContainer; 99 private ViewGroup mErrorContainer; 100 private ErrorFragment mErrorFragment; 101 private ViewGroup mSearchContainer; 102 103 private Toast mToast; 104 private AlertDialog mDialog; 105 106 /** Current state */ 107 private Mode mMode; 108 private Intent mCurrentSourcePreferences; 109 private boolean mCanShowMiniPlaybackControls; 110 private boolean mShouldShowSoundSettings; 111 private boolean mBrowseTreeHasChildren; 112 private PlaybackViewModel.PlaybackStateWrapper mCurrentPlaybackStateWrapper; 113 /** 114 * Media items to display as tabs. If null, it means we haven't finished loading them yet. If 115 * empty, it means there are no tabs to show 116 */ 117 @Nullable 118 private List<MediaItemMetadata> mTopItems; 119 120 private CarPackageManagerUtils mCarPackageManagerUtils; 121 private CarUxRestrictionsUtil mCarUxRestrictionsUtil; 122 private CarUxRestrictions mActiveCarUxRestrictions; 123 @CarUxRestrictions.CarUxRestrictionsInfo 124 private int mRestrictions; 125 private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener = 126 (carUxRestrictions) -> mActiveCarUxRestrictions = carUxRestrictions; 127 private Intent mAppSelectorIntent; 128 129 private boolean mAcceptTabSelection = true; 130 131 private AppBarView.AppBarListener mAppBarListener = new AppBarView.AppBarListener() { 132 @Override 133 public void onTabSelected(MediaItemMetadata item) { 134 if (mAcceptTabSelection) { 135 showTopItem(item); 136 } 137 } 138 139 @Override 140 public void onBack() { 141 BrowseFragment fragment = getCurrentBrowseFragment(); 142 if (fragment != null) { 143 boolean success = fragment.navigateBack(); 144 if (!success && (fragment == mSearchFragment)) { 145 changeMode(Mode.BROWSING); 146 } 147 } 148 } 149 150 @Override 151 public void onEqualizerSelection() { 152 Intent i = new Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL); 153 // Using startActivityForResult so that the control panel app can track changes for 154 // the launching package name. 155 startActivityForResult(i, 0); 156 } 157 158 @Override 159 public void onSettingsSelection() { 160 if (Log.isLoggable(TAG, Log.DEBUG)) { 161 Log.d(TAG, "onSettingsSelection"); 162 } 163 try { 164 if (mCurrentSourcePreferences != null) { 165 startActivity(mCurrentSourcePreferences); 166 } 167 } catch (ActivityNotFoundException e) { 168 if (Log.isLoggable(TAG, Log.ERROR)) { 169 Log.e(TAG, "onSettingsSelection " + e); 170 } 171 } 172 } 173 174 @Override 175 public void onSearchSelection() { 176 if (mMode == Mode.SEARCHING) { 177 mSearchFragment.reopenSearch(); 178 } else { 179 changeMode(Mode.SEARCHING); 180 } 181 } 182 183 @Override 184 public void onAppSwitch() { 185 MediaActivity.this.startActivity(mAppSelectorIntent); 186 } 187 188 @Override 189 public void onHeightChanged(int height) { 190 BrowseFragment fragment = getCurrentBrowseFragment(); 191 if (fragment != null) { 192 fragment.onAppBarHeightChanged(height); 193 } 194 } 195 196 @Override 197 public void onSearch(String query) { 198 if (Log.isLoggable(TAG, Log.DEBUG)) { 199 Log.d(TAG, "onSearch: " + query); 200 } 201 getInnerViewModel().setSearchQuery(query); 202 mSearchFragment.updateSearchQuery(query); 203 } 204 }; 205 206 private PlaybackFragment.PlaybackFragmentListener mPlaybackFragmentListener = 207 () -> changeMode(Mode.BROWSING); 208 209 /** 210 * Possible modes of the application UI 211 */ 212 private enum Mode { 213 /** The user is browsing a media source */ 214 BROWSING, 215 /** The user is interacting with the full screen playback UI */ 216 PLAYBACK, 217 /** The user is searching within a media source */ 218 SEARCHING, 219 /** There's no browse tree and playback doesn't work. */ 220 FATAL_ERROR 221 } 222 223 private static final Map<Integer, Integer> ERROR_CODE_MESSAGES_MAP; 224 225 static { 226 Map<Integer, Integer> map = new HashMap<>(); map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error)227 map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error); map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported)228 map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported); map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, R.string.error_code_authentication_expired)229 map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, 230 R.string.error_code_authentication_expired); map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, R.string.error_code_premium_account_required)231 map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, 232 R.string.error_code_premium_account_required); map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, R.string.error_code_concurrent_stream_limit)233 map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, 234 R.string.error_code_concurrent_stream_limit); map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, R.string.error_code_parental_control_restricted)235 map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, 236 R.string.error_code_parental_control_restricted); map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, R.string.error_code_not_available_in_region)237 map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, 238 R.string.error_code_not_available_in_region); map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, R.string.error_code_content_already_playing)239 map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, 240 R.string.error_code_content_already_playing); map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, R.string.error_code_skip_limit_reached)241 map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, 242 R.string.error_code_skip_limit_reached); map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted)243 map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted); map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue)244 map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue); 245 ERROR_CODE_MESSAGES_MAP = Collections.unmodifiableMap(map); 246 } 247 248 @Override onCreate(Bundle savedInstanceState)249 protected void onCreate(Bundle savedInstanceState) { 250 super.onCreate(savedInstanceState); 251 setContentView(R.layout.media_activity); 252 253 MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel(); 254 PlaybackViewModel playbackViewModel = getPlaybackViewModel(); 255 ViewModel localViewModel = getInnerViewModel(); 256 // We can't rely on savedInstanceState to determine whether the model has been initialized 257 // as on a config change savedInstanceState != null and the model is initialized, but if 258 // the app was killed by the system then savedInstanceState != null and the model is NOT 259 // initialized... 260 if (localViewModel.needsInitialization()) { 261 localViewModel.init(playbackViewModel); 262 } 263 mMode = localViewModel.getSavedMode(); 264 265 mRootView = findViewById(R.id.media_activity_root); 266 mAppBarView = findViewById(R.id.app_bar); 267 mAppBarView.setListener(mAppBarListener); 268 mediaSourceViewModel.getPrimaryMediaSource().observe(this, 269 this::onMediaSourceChanged); 270 271 MediaBrowserViewModel mediaBrowserViewModel = getRootBrowserViewModel(); 272 mediaBrowserViewModel.getBrowseState().observe(this, 273 browseState -> mBrowseFragment.setRootState(browseState, 274 mediaSourceViewModel.getPrimaryMediaSource().getValue())); 275 mediaBrowserViewModel.getBrowsedMediaItems().observe(this, futureData -> { 276 if (futureData.isLoading()) { 277 if (Log.isLoggable(TAG, Log.INFO)) { 278 Log.i(TAG, "Loading browse tree..."); 279 } 280 mBrowseTreeHasChildren = false; 281 updateTabs(null); 282 return; 283 } 284 final boolean browseTreeHasChildren = 285 futureData.getData() != null && !futureData.getData().isEmpty(); 286 if (Log.isLoggable(TAG, Log.INFO)) { 287 Log.i(TAG, "Browse tree loaded, status (has children or not) changed: " 288 + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren); 289 } 290 mBrowseTreeHasChildren = browseTreeHasChildren; 291 handlePlaybackState(playbackViewModel.getPlaybackStateWrapper().getValue(), false); 292 updateTabs(futureData.getData() != null ? futureData.getData() : new ArrayList<>()); 293 }); 294 mediaBrowserViewModel.supportsSearch().observe(this, 295 mAppBarView::setSearchSupported); 296 297 mPlaybackFragment = new PlaybackFragment(); 298 mPlaybackFragment.setListener(mPlaybackFragmentListener); 299 300 301 Size maxArtSize = MediaAppConfig.getMediaItemsBitmapMaxSize(this); 302 mMiniPlaybackControls = findViewById(R.id.minimized_playback_controls); 303 mMiniPlaybackControls.setModel(playbackViewModel, this, maxArtSize); 304 mMiniPlaybackControls.setOnClickListener(view -> changeMode(Mode.PLAYBACK)); 305 306 mFadeDuration = getResources().getInteger(R.integer.new_album_art_fade_in_duration); 307 mBrowseContainer = findViewById(R.id.fragment_container); 308 mErrorContainer = findViewById(R.id.error_container); 309 mPlaybackContainer = findViewById(R.id.playback_container); 310 mSearchContainer = findViewById(R.id.search_container); 311 getSupportFragmentManager().beginTransaction() 312 .replace(R.id.playback_container, mPlaybackFragment) 313 .commit(); 314 315 mBrowseFragment = BrowseFragment.newInstance(this, mBrowseContainer); 316 mSearchFragment = BrowseFragment.newSearchInstance(this, mSearchContainer); 317 318 playbackViewModel.getPlaybackController().observe(this, 319 playbackController -> { 320 if (playbackController != null) playbackController.prepare(); 321 mPlaybackController = playbackController; 322 }); 323 324 playbackViewModel.getPlaybackStateWrapper().observe(this, 325 state -> handlePlaybackState(state, true)); 326 327 mCarPackageManagerUtils = CarPackageManagerUtils.getInstance(this); 328 mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(this); 329 mRestrictions = CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP; 330 mCarUxRestrictionsUtil.register(mListener); 331 mShouldShowSoundSettings = getResources().getBoolean(R.bool.show_sound_settings); 332 333 mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this)); 334 mAppSelectorIntent = MediaSource.getSourceSelectorIntent(this, false); 335 mAppBarView.setAppLauncherSupported(mAppSelectorIntent != null); 336 337 localViewModel.getMiniControlsVisible().observe(this, visible -> { 338 int topMargin = mAppBarView.getHeight(); 339 int bottomMargin = visible ? mMiniPlaybackControls.getHeight() : 0; 340 mBrowseFragment.onPlaybackControlsChanged(visible, topMargin, bottomMargin); 341 mSearchFragment.onPlaybackControlsChanged(visible, topMargin, bottomMargin); 342 } ); 343 } 344 345 @Override onDestroy()346 protected void onDestroy() { 347 mCarUxRestrictionsUtil.unregister(mListener); 348 super.onDestroy(); 349 } 350 isUxRestricted()351 private boolean isUxRestricted() { 352 return CarUxRestrictionsUtil.isRestricted(mRestrictions, mActiveCarUxRestrictions); 353 } 354 handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, boolean ignoreSameState)355 private void handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, 356 boolean ignoreSameState) { 357 if (Log.isLoggable(TAG, Log.DEBUG)) { 358 Log.d(TAG, 359 "handlePlaybackState(); state change: " + (mCurrentPlaybackStateWrapper != null 360 ? mCurrentPlaybackStateWrapper.getState() : null) + " -> " + ( 361 state != null ? state.getState() : null)); 362 363 } 364 365 // TODO(arnaudberry) rethink interactions between customized layouts and dynamic visibility. 366 mCanShowMiniPlaybackControls = (state != null) && state.shouldDisplay(); 367 updateMiniPlaybackControls(true); 368 369 if (state == null) { 370 mCurrentPlaybackStateWrapper = null; 371 return; 372 } 373 374 String displayedMessage = getDisplayedMessage(state); 375 if (Log.isLoggable(TAG, Log.DEBUG)) { 376 Log.d(TAG, "Displayed error message: [" + displayedMessage + "]"); 377 } 378 if (ignoreSameState && mCurrentPlaybackStateWrapper != null 379 && mCurrentPlaybackStateWrapper.getState() == state.getState() 380 && TextUtils.equals(displayedMessage, 381 getDisplayedMessage(mCurrentPlaybackStateWrapper))) { 382 if (Log.isLoggable(TAG, Log.DEBUG)) { 383 Log.d(TAG, "Ignore same playback state."); 384 } 385 return; 386 } 387 388 mCurrentPlaybackStateWrapper = state; 389 390 maybeCancelToast(); 391 maybeCancelDialog(); 392 393 Bundle extras = state.getExtras(); 394 PendingIntent intent = extras == null ? null : extras.getParcelable( 395 MediaConstants.ERROR_RESOLUTION_ACTION_INTENT); 396 String label = extras == null ? null : extras.getString( 397 MediaConstants.ERROR_RESOLUTION_ACTION_LABEL); 398 399 boolean isFatalError = false; 400 if (!TextUtils.isEmpty(displayedMessage)) { 401 if (mBrowseTreeHasChildren) { 402 if (intent != null && !isUxRestricted()) { 403 showDialog(intent, displayedMessage, label, getString(android.R.string.cancel)); 404 } else { 405 showToast(displayedMessage); 406 } 407 } else { 408 mErrorFragment = ErrorFragment.newInstance(displayedMessage, label, intent); 409 setErrorFragment(mErrorFragment); 410 isFatalError = true; 411 } 412 } 413 if (isFatalError) { 414 changeMode(Mode.FATAL_ERROR); 415 } else if (mMode == Mode.FATAL_ERROR) { 416 changeMode(Mode.BROWSING); 417 } 418 } 419 getDisplayedMessage(@ullable PlaybackViewModel.PlaybackStateWrapper state)420 private String getDisplayedMessage(@Nullable PlaybackViewModel.PlaybackStateWrapper state) { 421 if (state == null) { 422 return null; 423 } 424 if (!TextUtils.isEmpty(state.getErrorMessage())) { 425 return state.getErrorMessage().toString(); 426 } 427 // ERROR_CODE_UNKNOWN_ERROR means there is no error in PlaybackState. 428 if (state.getErrorCode() != PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR) { 429 Integer messageId = ERROR_CODE_MESSAGES_MAP.get(state.getErrorCode()); 430 return messageId != null ? getString(messageId) : getString( 431 R.string.default_error_message); 432 } 433 if (state.getState() == PlaybackStateCompat.STATE_ERROR) { 434 return getString(R.string.default_error_message); 435 } 436 return null; 437 } 438 showDialog(PendingIntent intent, String message, String positiveBtnText, String negativeButtonText)439 private void showDialog(PendingIntent intent, String message, String positiveBtnText, 440 String negativeButtonText) { 441 AlertDialogBuilder dialog = new AlertDialogBuilder(this); 442 mDialog = dialog.setMessage(message) 443 .setNegativeButton(negativeButtonText, null) 444 .setPositiveButton(positiveBtnText, (dialogInterface, i) -> { 445 try { 446 intent.send(); 447 } catch (PendingIntent.CanceledException e) { 448 if (Log.isLoggable(TAG, Log.ERROR)) { 449 Log.e(TAG, "Pending intent canceled"); 450 } 451 } 452 }) 453 .show(); 454 } 455 maybeCancelDialog()456 private void maybeCancelDialog() { 457 if (mDialog != null) { 458 mDialog.cancel(); 459 mDialog = null; 460 } 461 } 462 showToast(String message)463 private void showToast(String message) { 464 mToast = Toast.makeText(this, message, Toast.LENGTH_LONG); 465 mToast.show(); 466 } 467 maybeCancelToast()468 private void maybeCancelToast() { 469 if (mToast != null) { 470 mToast.cancel(); 471 mToast = null; 472 } 473 } 474 475 @Override onBackPressed()476 public void onBackPressed() { 477 super.onBackPressed(); 478 } 479 480 /** 481 * Sets the media source being browsed. 482 * 483 * @param mediaSource the new media source we are going to try to browse 484 */ onMediaSourceChanged(@ullable MediaSource mediaSource)485 private void onMediaSourceChanged(@Nullable MediaSource mediaSource) { 486 ComponentName savedMediaSource = getInnerViewModel().getSavedMediaSource(); 487 if (Log.isLoggable(TAG, Log.INFO)) { 488 Log.i(TAG, "MediaSource changed from " + savedMediaSource + " to " + mediaSource); 489 } 490 491 savedMediaSource = mediaSource != null ? mediaSource.getBrowseServiceComponentName() : null; 492 getInnerViewModel().saveMediaSource(savedMediaSource); 493 494 mBrowseFragment.resetState(); 495 mSearchFragment.resetState(); 496 497 mBrowseTreeHasChildren = false; 498 mCurrentPlaybackStateWrapper = null; 499 maybeCancelToast(); 500 maybeCancelDialog(); 501 Drawable icon = mediaSource != null 502 ? new BitmapDrawable(getResources(), mediaSource.getRoundPackageIcon()) 503 : null; 504 Drawable searchIcon = mediaSource != null 505 ? new BitmapDrawable(getResources(), mediaSource.getRoundPackageIcon()) 506 : null; 507 // The Drawables can't be shared as the layout manager scales the underlying drawable object 508 // when performing the layout thus can cause inconsistencies. 509 mAppBarView.setLogo(icon); 510 mAppBarView.setSearchIcon(searchIcon); 511 if (mediaSource != null) { 512 if (Log.isLoggable(TAG, Log.INFO)) { 513 Log.i(TAG, "Browsing: " + mediaSource.getDisplayName()); 514 } 515 updateTabs(null); 516 Mode mediaSourceMode = getInnerViewModel().getSavedMode(); 517 // Changes the mode regardless of its previous value so that the views can be updated. 518 changeModeInternal(mediaSourceMode, false); 519 updateSourcePreferences(mediaSource.getPackageName()); 520 // Always go through the trampoline activity to keep all the dispatching logic there. 521 startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE)); 522 } else { 523 updateTabs(new ArrayList<>()); 524 updateSourcePreferences(null); 525 } 526 } 527 528 // TODO(b/136274938): display the preference screen for each media service. updateSourcePreferences(@ullable String packageName)529 private void updateSourcePreferences(@Nullable String packageName) { 530 mCurrentSourcePreferences = null; 531 if (packageName != null) { 532 Intent prefsIntent = new Intent(Intent.ACTION_APPLICATION_PREFERENCES); 533 prefsIntent.setPackage(packageName); 534 ResolveInfo info = getPackageManager().resolveActivity(prefsIntent, 0); 535 if (info != null && info.activityInfo != null && info.activityInfo.exported) { 536 mCurrentSourcePreferences = new Intent(prefsIntent.getAction()) 537 .setClassName(info.activityInfo.packageName, info.activityInfo.name); 538 mAppBarView.setSettingsDistractionOptimized( 539 mCarPackageManagerUtils.isDistractionOptimized(info.activityInfo)); 540 } 541 } 542 mAppBarView.setHasSettings(mCurrentSourcePreferences != null); 543 mAppBarView.setHasEqualizer(mShouldShowSoundSettings); 544 } 545 546 547 /** 548 * Updates the tabs displayed on the app bar, based on the top level items on the browse tree. 549 * If there is at least one browsable item, we show the browse content of that node. If there 550 * are only playable items, then we show those items. If there are not items at all, we show the 551 * empty message. If we receive null, we show the error message. 552 * 553 * @param items top level items, null if the items are still being loaded, or empty list if 554 * items couldn't be loaded. 555 */ updateTabs(@ullable List<MediaItemMetadata> items)556 private void updateTabs(@Nullable List<MediaItemMetadata> items) { 557 // Keep mTopItems null when the items are being loaded so that updateAppBarTitle() can 558 // handle that case specifically. 559 List<MediaItemMetadata> browsableTopLevel = (items == null) ? null : 560 items.stream().filter(MediaItemMetadata::isBrowsable).collect(Collectors.toList()); 561 562 if (Objects.equals(mTopItems, browsableTopLevel)) { 563 // When coming back to the app, the live data sends an update even if the list hasn't 564 // changed. Updating the tabs then recreates the browse fragment, which produces jank 565 // (b/131830876), and also resets the navigation to the top of the first tab... 566 return; 567 } 568 mTopItems = browsableTopLevel; 569 570 if (mTopItems == null || mTopItems.isEmpty()) { 571 mAppBarView.setItems(null); 572 mAppBarView.setActiveItem(null); 573 if (items != null) { 574 // Only do this when not loading the tabs or we loose the saved one. 575 showTopItem(null); 576 } 577 updateAppBar(); 578 return; 579 } 580 581 ViewModel innerModel = getInnerViewModel(); 582 MediaItemMetadata oldTab = innerModel.getSelectedTab(); 583 try { 584 mAcceptTabSelection = false; 585 mAppBarView.setItems(mTopItems.size() == 1 ? null : mTopItems); 586 updateAppBar(); 587 588 if (browsableTopLevel.contains(oldTab)) { 589 mAppBarView.setActiveItem(oldTab); 590 } else { 591 showTopItem(browsableTopLevel.get(0)); 592 } 593 } finally { 594 mAcceptTabSelection = true; 595 } 596 } 597 showTopItem(@ullable MediaItemMetadata item)598 private void showTopItem(@Nullable MediaItemMetadata item) { 599 getInnerViewModel().getBrowseStack().clear(); 600 mBrowseFragment.navigateInto(item); 601 } 602 setErrorFragment(Fragment fragment)603 private void setErrorFragment(Fragment fragment) { 604 getSupportFragmentManager().beginTransaction() 605 .replace(R.id.error_container, fragment) 606 .commitAllowingStateLoss(); 607 } 608 609 @Nullable getCurrentBrowseFragment()610 private BrowseFragment getCurrentBrowseFragment() { 611 return mMode == Mode.SEARCHING ? mSearchFragment : mBrowseFragment; 612 } 613 changeMode(Mode mode)614 private void changeMode(Mode mode) { 615 if (mMode == mode) { 616 if (Log.isLoggable(TAG, Log.INFO)) { 617 Log.i(TAG, "Mode " + mMode + " change is ignored"); 618 } 619 return; 620 } 621 changeModeInternal(mode, true); 622 } 623 changeModeInternal(Mode mode, boolean hideViewAnimated)624 private void changeModeInternal(Mode mode, boolean hideViewAnimated) { 625 if (Log.isLoggable(TAG, Log.INFO)) { 626 Log.i(TAG, "Changing mode from: " + mMode + " to: " + mode); 627 } 628 int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0; 629 630 Mode oldMode = mMode; 631 getInnerViewModel().saveMode(mode); 632 mMode = mode; 633 634 mPlaybackFragment.closeOverflowMenu(); 635 updateMiniPlaybackControls(hideViewAnimated); 636 637 switch (mMode) { 638 case FATAL_ERROR: 639 ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration); 640 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 641 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 642 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 643 mAppBarView.setState(Toolbar.State.HOME); 644 mAppBarView.showSearchIfSupported(false); 645 ViewUtils.showViewAnimated(mAppBarView, mFadeDuration); 646 break; 647 case PLAYBACK: 648 mPlaybackContainer.setY(0); 649 mPlaybackContainer.setAlpha(0f); 650 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 651 ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration); 652 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 653 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 654 ViewUtils.hideViewAnimated(mAppBarView, fadeOutDuration); 655 break; 656 case BROWSING: 657 if (oldMode == Mode.PLAYBACK) { 658 ViewUtils.hideViewAnimated(mErrorContainer, 0); 659 ViewUtils.showViewAnimated(mBrowseContainer, 0); 660 ViewUtils.hideViewAnimated(mSearchContainer, 0); 661 ViewUtils.showViewAnimated(mAppBarView, 0); 662 mPlaybackContainer.animate() 663 .translationY(mRootView.getHeight()) 664 .setDuration(fadeOutDuration) 665 .setListener(ViewUtils.hideViewAfterAnimation(mPlaybackContainer)) 666 .start(); 667 } else { 668 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 669 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 670 ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration); 671 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 672 ViewUtils.showViewAnimated(mAppBarView, mFadeDuration); 673 } 674 updateAppBar(); 675 break; 676 case SEARCHING: 677 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 678 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 679 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 680 ViewUtils.showViewAnimated(mSearchContainer, mFadeDuration); 681 ViewUtils.showViewAnimated(mAppBarView, mFadeDuration); 682 updateAppBar(); 683 break; 684 } 685 } updateAppBarTitle()686 private void updateAppBarTitle() { 687 BrowseFragment fragment = getCurrentBrowseFragment(); 688 boolean isStacked = fragment != null && !fragment.isAtTopStack(); 689 690 final CharSequence title; 691 if (isStacked) { 692 // If not at top level, show the current item as title 693 title = fragment.getCurrentMediaItem().getTitle(); 694 } else if (mTopItems == null) { 695 // If still loading the tabs, force to show an empty bar. 696 title = ""; 697 } else if (mTopItems.size() == 1) { 698 // If we finished loading tabs and there is only one, use that as title. 699 title = mTopItems.get(0).getTitle(); 700 } else { 701 // Otherwise (no tabs or more than 1 tabs), show the current media source title. 702 MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel(); 703 MediaSource mediaSource = mediaSourceViewModel.getPrimaryMediaSource().getValue(); 704 title = (mediaSource != null) ? mediaSource.getDisplayName() 705 : getResources().getString(R.string.media_app_title); 706 } 707 708 mAppBarView.setTitle(title); 709 } 710 711 /** 712 * Update elements of the appbar that change depending on where we are in the browse. 713 */ updateAppBar()714 private void updateAppBar() { 715 BrowseFragment fragment = getCurrentBrowseFragment(); 716 boolean isStacked = fragment != null && !fragment.isAtTopStack(); 717 if (Log.isLoggable(TAG, Log.DEBUG)) { 718 Log.d(TAG, "App bar is in stacked state: " + isStacked); 719 } 720 Toolbar.State unstackedState = mMode == Mode.SEARCHING 721 ? Toolbar.State.SEARCH 722 : Toolbar.State.HOME; 723 updateAppBarTitle(); 724 mAppBarView.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState); 725 726 boolean showSearchItem = mMode != Mode.FATAL_ERROR && mMode != Mode.SEARCHING; 727 mAppBarView.showSearchIfSupported(showSearchItem); 728 } 729 updateMiniPlaybackControls(boolean hideViewAnimated)730 private void updateMiniPlaybackControls(boolean hideViewAnimated) { 731 int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0; 732 // Minimized control bar should be hidden in playback view. 733 final boolean shouldShowMiniPlaybackControls = 734 mCanShowMiniPlaybackControls && mMode != Mode.PLAYBACK; 735 if (shouldShowMiniPlaybackControls) { 736 ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration); 737 } else { 738 ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration); 739 } 740 getInnerViewModel().setMiniControlsVisible(shouldShowMiniPlaybackControls); 741 } 742 743 @Override onBackStackChanged()744 public void onBackStackChanged() { 745 updateAppBar(); 746 } 747 748 @Override onPlayableItemClicked(MediaItemMetadata item)749 public void onPlayableItemClicked(MediaItemMetadata item) { 750 mPlaybackController.playItem(item); 751 boolean switchToPlayback = getResources().getBoolean( 752 R.bool.switch_to_playback_view_when_playable_item_is_clicked); 753 if (switchToPlayback) { 754 changeMode(Mode.PLAYBACK); 755 } else if (mMode == Mode.SEARCHING) { 756 changeMode(Mode.BROWSING); 757 } 758 setIntent(null); 759 } 760 761 @Override getActivity()762 public FragmentActivity getActivity() { 763 return this; 764 } 765 getMediaSourceViewModel()766 public MediaSourceViewModel getMediaSourceViewModel() { 767 return MediaSourceViewModel.get(getApplication()); 768 } 769 getPlaybackViewModel()770 public PlaybackViewModel getPlaybackViewModel() { 771 return PlaybackViewModel.get(getApplication()); 772 } 773 getRootBrowserViewModel()774 private MediaBrowserViewModel getRootBrowserViewModel() { 775 return MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(getMediaSourceViewModel(), 776 ViewModelProviders.of(this)); 777 } 778 getInnerViewModel()779 public ViewModel getInnerViewModel() { 780 return ViewModelProviders.of(this).get(ViewModel.class); 781 } 782 783 public static class ViewModel extends AndroidViewModel { 784 785 static class MediaServiceState { 786 Mode mMode = Mode.BROWSING; 787 Stack<MediaItemMetadata> mBrowseStack = new Stack<>(); 788 Stack<MediaItemMetadata> mSearchStack = new Stack<>(); 789 String mSearchQuery; 790 boolean mQueueVisible = false; 791 } 792 793 private boolean mNeedsInitialization = true; 794 private PlaybackViewModel mPlaybackViewModel; 795 private ComponentName mMediaSource; 796 private final Map<ComponentName, MediaServiceState> mStates = new HashMap<>(); 797 private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>(); 798 ViewModel(@onNull Application application)799 public ViewModel(@NonNull Application application) { 800 super(application); 801 } 802 init(@onNull PlaybackViewModel playbackViewModel)803 void init(@NonNull PlaybackViewModel playbackViewModel) { 804 if (mPlaybackViewModel == playbackViewModel) { 805 return; 806 } 807 mPlaybackViewModel = playbackViewModel; 808 mNeedsInitialization = false; 809 } 810 needsInitialization()811 boolean needsInitialization() { 812 return mNeedsInitialization; 813 } 814 setMiniControlsVisible(boolean visible)815 void setMiniControlsVisible(boolean visible) { 816 mIsMiniControlsVisible.setValue(visible); 817 } 818 getMiniControlsVisible()819 LiveData<Boolean> getMiniControlsVisible() { 820 return mIsMiniControlsVisible; 821 } 822 getSavedState()823 MediaServiceState getSavedState() { 824 MediaServiceState state = mStates.get(mMediaSource); 825 if (state == null) { 826 state = new MediaServiceState(); 827 mStates.put(mMediaSource, state); 828 } 829 return state; 830 } 831 saveMode(Mode mode)832 void saveMode(Mode mode) { 833 getSavedState().mMode = mode; 834 } 835 getSavedMode()836 Mode getSavedMode() { 837 return getSavedState().mMode; 838 } 839 getSelectedTab()840 @Nullable MediaItemMetadata getSelectedTab() { 841 Stack<MediaItemMetadata> stack = getSavedState().mBrowseStack; 842 return (stack != null && !stack.empty()) ? stack.firstElement() : null; 843 } 844 setQueueVisible(boolean visible)845 void setQueueVisible(boolean visible) { 846 getSavedState().mQueueVisible = visible; 847 } 848 getQueueVisible()849 boolean getQueueVisible() { 850 return getSavedState().mQueueVisible; 851 } 852 saveMediaSource(ComponentName mediaSource)853 void saveMediaSource(ComponentName mediaSource) { 854 mMediaSource = mediaSource; 855 } 856 getSavedMediaSource()857 ComponentName getSavedMediaSource() { 858 return mMediaSource; 859 } 860 getBrowseStack()861 Stack<MediaItemMetadata> getBrowseStack() { 862 return getSavedState().mBrowseStack; 863 } 864 getSearchStack()865 Stack<MediaItemMetadata> getSearchStack() { 866 return getSavedState().mSearchStack; 867 } 868 getSearchQuery()869 String getSearchQuery() { 870 return getSavedState().mSearchQuery; 871 } 872 setSearchQuery(String searchQuery)873 void setSearchQuery(String searchQuery) { 874 getSavedState().mSearchQuery = searchQuery; 875 } 876 } 877 878 private class ClosePlaybackDetector extends GestureDetector.SimpleOnGestureListener 879 implements View.OnTouchListener { 880 881 private final ViewConfiguration mViewConfig; 882 private final GestureDetectorCompat mDetector; 883 884 ClosePlaybackDetector(Context context)885 ClosePlaybackDetector(Context context) { 886 mViewConfig = ViewConfiguration.get(context); 887 mDetector = new GestureDetectorCompat(context, this); 888 } 889 890 @SuppressLint("ClickableViewAccessibility") 891 @Override onTouch(View v, MotionEvent event)892 public boolean onTouch(View v, MotionEvent event) { 893 return mDetector.onTouchEvent(event); 894 } 895 896 @Override onDown(MotionEvent event)897 public boolean onDown(MotionEvent event) { 898 return (mMode == Mode.PLAYBACK); 899 } 900 901 @Override onFling(MotionEvent e1, MotionEvent e2, float vX, float vY)902 public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) { 903 float dY = e2.getY() - e1.getY(); 904 if (dY > mViewConfig.getScaledTouchSlop() && 905 Math.abs(vY) > mViewConfig.getScaledMinimumFlingVelocity()) { 906 float dX = e2.getX() - e1.getX(); 907 float tan = Math.abs(dX) / dY; 908 if (tan <= 0.58) { // Accept 30 degrees on each side of the down vector. 909 changeMode(Mode.BROWSING); 910 } 911 } 912 return true; 913 } 914 } 915 } 916