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 static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_BROWSE; 19 20 import static com.android.car.apps.common.util.VectorMath.EPSILON; 21 22 import android.annotation.SuppressLint; 23 import android.app.AlertDialog; 24 import android.app.Application; 25 import android.app.PendingIntent; 26 import android.car.Car; 27 import android.car.content.pm.CarPackageManager; 28 import android.car.drivingstate.CarUxRestrictions; 29 import android.content.ComponentName; 30 import android.content.Context; 31 import android.content.Intent; 32 import android.content.res.Resources; 33 import android.os.Bundle; 34 import android.support.v4.media.session.PlaybackStateCompat; 35 import android.text.TextUtils; 36 import android.util.Log; 37 import android.util.Size; 38 import android.view.GestureDetector; 39 import android.view.MotionEvent; 40 import android.view.View; 41 import android.view.ViewConfiguration; 42 import android.view.ViewGroup; 43 import android.widget.Toast; 44 45 import androidx.annotation.NonNull; 46 import androidx.annotation.Nullable; 47 import androidx.core.view.GestureDetectorCompat; 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.util.VectorMath; 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.playback.PlaybackViewModel; 61 import com.android.car.media.common.source.MediaSource; 62 import com.android.car.media.common.source.MediaSourceViewModel; 63 import com.android.car.ui.AlertDialogBuilder; 64 import com.android.car.ui.utils.CarUxRestrictionsUtil; 65 66 import java.util.Collections; 67 import java.util.HashMap; 68 import java.util.Map; 69 import java.util.Stack; 70 71 /** 72 * This activity controls the UI of media. It also updates the connection status for the media app 73 * by broadcast. 74 */ 75 public class MediaActivity extends FragmentActivity implements BrowseViewController.Callbacks { 76 private static final String TAG = "MediaActivity"; 77 78 /** Configuration (controlled from resources) */ 79 private int mFadeDuration; 80 81 /** Models */ 82 private PlaybackViewModel.PlaybackController mPlaybackController; 83 84 /** Layout views */ 85 private View mRootView; 86 private PlaybackFragment mPlaybackFragment; 87 private BrowseViewController mSearchController; 88 private BrowseViewController mBrowseController; 89 private MinimizedPlaybackControlBar mMiniPlaybackControls; 90 private ViewGroup mBrowseContainer; 91 private ViewGroup mPlaybackContainer; 92 private ViewGroup mErrorContainer; 93 private ErrorViewController mErrorController; 94 private ViewGroup mSearchContainer; 95 96 private Toast mToast; 97 private AlertDialog mDialog; 98 99 /** Current state */ 100 private Mode mMode; 101 private boolean mCanShowMiniPlaybackControls; 102 private PlaybackViewModel.PlaybackStateWrapper mCurrentPlaybackStateWrapper; 103 104 private Car mCar; 105 private CarPackageManager mCarPackageManager; 106 107 private float mCloseVectorX; 108 private float mCloseVectorY; 109 private float mCloseVectorNorm; 110 111 private CarUxRestrictionsUtil mCarUxRestrictionsUtil; 112 private CarUxRestrictions mActiveCarUxRestrictions; 113 @CarUxRestrictions.CarUxRestrictionsInfo 114 private int mRestrictions; 115 private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener = 116 (carUxRestrictions) -> mActiveCarUxRestrictions = carUxRestrictions; 117 118 119 private PlaybackFragment.PlaybackFragmentListener mPlaybackFragmentListener = 120 () -> changeMode(Mode.BROWSING); 121 122 /** 123 * Possible modes of the application UI 124 */ 125 enum Mode { 126 /** The user is browsing a media source */ 127 BROWSING, 128 /** The user is interacting with the full screen playback UI */ 129 PLAYBACK, 130 /** The user is searching within a media source */ 131 SEARCHING, 132 /** There's no browse tree and playback doesn't work. */ 133 FATAL_ERROR 134 } 135 136 private static final Map<Integer, Integer> ERROR_CODE_MESSAGES_MAP; 137 138 static { 139 Map<Integer, Integer> map = new HashMap<>(); map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error)140 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)141 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)142 map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, 143 R.string.error_code_authentication_expired); map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, R.string.error_code_premium_account_required)144 map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, 145 R.string.error_code_premium_account_required); map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, R.string.error_code_concurrent_stream_limit)146 map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, 147 R.string.error_code_concurrent_stream_limit); map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, R.string.error_code_parental_control_restricted)148 map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, 149 R.string.error_code_parental_control_restricted); map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, R.string.error_code_not_available_in_region)150 map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, 151 R.string.error_code_not_available_in_region); map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, R.string.error_code_content_already_playing)152 map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, 153 R.string.error_code_content_already_playing); map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, R.string.error_code_skip_limit_reached)154 map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, 155 R.string.error_code_skip_limit_reached); map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted)156 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)157 map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue); 158 ERROR_CODE_MESSAGES_MAP = Collections.unmodifiableMap(map); 159 } 160 161 @Override onCreate(Bundle savedInstanceState)162 protected void onCreate(Bundle savedInstanceState) { 163 super.onCreate(savedInstanceState); 164 setContentView(R.layout.media_activity); 165 166 Resources res = getResources(); 167 mCloseVectorX = res.getFloat(R.dimen.media_activity_close_vector_x); 168 mCloseVectorY = res.getFloat(R.dimen.media_activity_close_vector_y); 169 mCloseVectorNorm = VectorMath.norm2(mCloseVectorX, mCloseVectorY); 170 171 172 MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel(); 173 // TODO(b/151174811): Use appropriate modes, instead of just MEDIA_SOURCE_MODE_BROWSE 174 PlaybackViewModel playbackViewModel = getPlaybackViewModel(); 175 ViewModel localViewModel = getInnerViewModel(); 176 // We can't rely on savedInstanceState to determine whether the model has been initialized 177 // as on a config change savedInstanceState != null and the model is initialized, but if 178 // the app was killed by the system then savedInstanceState != null and the model is NOT 179 // initialized... 180 if (localViewModel.needsInitialization()) { 181 localViewModel.init(playbackViewModel); 182 } 183 mMode = localViewModel.getSavedMode(); 184 185 mRootView = findViewById(R.id.media_activity_root); 186 187 mediaSourceViewModel.getPrimaryMediaSource().observe(this, 188 this::onMediaSourceChanged); 189 190 mPlaybackFragment = new PlaybackFragment(); 191 mPlaybackFragment.setListener(mPlaybackFragmentListener); 192 193 194 Size maxArtSize = MediaAppConfig.getMediaItemsBitmapMaxSize(this); 195 mMiniPlaybackControls = findViewById(R.id.minimized_playback_controls); 196 mMiniPlaybackControls.setModel(playbackViewModel, this, maxArtSize); 197 mMiniPlaybackControls.setOnClickListener(view -> changeMode(Mode.PLAYBACK)); 198 199 mFadeDuration = res.getInteger(R.integer.new_album_art_fade_in_duration); 200 mBrowseContainer = findViewById(R.id.fragment_container); 201 mErrorContainer = findViewById(R.id.error_container); 202 mPlaybackContainer = findViewById(R.id.playback_container); 203 mSearchContainer = findViewById(R.id.search_container); 204 getSupportFragmentManager().beginTransaction() 205 .replace(R.id.playback_container, mPlaybackFragment) 206 .commit(); 207 208 mBrowseController = BrowseViewController.newInstance(this, 209 mCarPackageManager, mBrowseContainer); 210 mSearchController = BrowseViewController.newSearchInstance(this, 211 mCarPackageManager, mSearchContainer); 212 213 playbackViewModel.getPlaybackController().observe(this, 214 playbackController -> { 215 if (playbackController != null) playbackController.prepare(); 216 mPlaybackController = playbackController; 217 }); 218 219 playbackViewModel.getPlaybackStateWrapper().observe(this, 220 state -> handlePlaybackState(state, true)); 221 222 mCar = Car.createCar(this); 223 mCarPackageManager = (CarPackageManager) mCar.getCarManager(Car.PACKAGE_SERVICE); 224 225 mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(this); 226 mRestrictions = CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP; 227 mCarUxRestrictionsUtil.register(mListener); 228 229 mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this)); 230 231 localViewModel.getMiniControlsVisible().observe(this, visible -> { 232 mBrowseController.onPlaybackControlsChanged(visible); 233 mSearchController.onPlaybackControlsChanged(visible); 234 }); 235 } 236 237 @Override onDestroy()238 protected void onDestroy() { 239 mCarUxRestrictionsUtil.unregister(mListener); 240 mCar.disconnect(); 241 super.onDestroy(); 242 } 243 isUxRestricted()244 private boolean isUxRestricted() { 245 return CarUxRestrictionsUtil.isRestricted(mRestrictions, mActiveCarUxRestrictions); 246 } 247 handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, boolean ignoreSameState)248 private void handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, 249 boolean ignoreSameState) { 250 if (Log.isLoggable(TAG, Log.DEBUG)) { 251 Log.d(TAG, 252 "handlePlaybackState(); state change: " + (mCurrentPlaybackStateWrapper != null 253 ? mCurrentPlaybackStateWrapper.getState() : null) + " -> " + ( 254 state != null ? state.getState() : null)); 255 256 } 257 258 // TODO(arnaudberry) rethink interactions between customized layouts and dynamic visibility. 259 mCanShowMiniPlaybackControls = (state != null) && state.shouldDisplay(); 260 updateMiniPlaybackControls(true); 261 262 if (state == null) { 263 mCurrentPlaybackStateWrapper = null; 264 return; 265 } 266 267 String displayedMessage = getDisplayedMessage(state); 268 if (Log.isLoggable(TAG, Log.DEBUG)) { 269 Log.d(TAG, "Displayed error message: [" + displayedMessage + "]"); 270 } 271 if (ignoreSameState && mCurrentPlaybackStateWrapper != null 272 && mCurrentPlaybackStateWrapper.getState() == state.getState() 273 && TextUtils.equals(displayedMessage, 274 getDisplayedMessage(mCurrentPlaybackStateWrapper))) { 275 if (Log.isLoggable(TAG, Log.DEBUG)) { 276 Log.d(TAG, "Ignore same playback state."); 277 } 278 return; 279 } 280 281 mCurrentPlaybackStateWrapper = state; 282 283 maybeCancelToast(); 284 maybeCancelDialog(); 285 286 Bundle extras = state.getExtras(); 287 PendingIntent intent = extras == null ? null : extras.getParcelable( 288 MediaConstants.ERROR_RESOLUTION_ACTION_INTENT); 289 String label = extras == null ? null : extras.getString( 290 MediaConstants.ERROR_RESOLUTION_ACTION_LABEL); 291 292 boolean isFatalError = false; 293 if (!TextUtils.isEmpty(displayedMessage)) { 294 if (mBrowseController.browseTreeHasChildren()) { 295 if (intent != null && !isUxRestricted()) { 296 showDialog(intent, displayedMessage, label, getString(android.R.string.cancel)); 297 } else { 298 showToast(displayedMessage); 299 } 300 } else { 301 getErrorController().setError(displayedMessage, label, intent, 302 CarPackageManagerUtils.isDistractionOptimized(mCarPackageManager, intent)); 303 isFatalError = true; 304 } 305 } 306 if (isFatalError) { 307 changeMode(Mode.FATAL_ERROR); 308 } else if (mMode == Mode.FATAL_ERROR) { 309 changeMode(Mode.BROWSING); 310 } 311 } 312 getErrorController()313 private ErrorViewController getErrorController() { 314 if (mErrorController == null) { 315 mErrorController = new ErrorViewController(this, mCarPackageManager, mErrorContainer); 316 MediaSource mediaSource = getMediaSourceViewModel().getPrimaryMediaSource().getValue(); 317 mErrorController.onMediaSourceChanged(mediaSource); 318 } 319 return mErrorController; 320 } 321 getDisplayedMessage(@ullable PlaybackViewModel.PlaybackStateWrapper state)322 private String getDisplayedMessage(@Nullable PlaybackViewModel.PlaybackStateWrapper state) { 323 if (state == null) { 324 return null; 325 } 326 if (!TextUtils.isEmpty(state.getErrorMessage())) { 327 return state.getErrorMessage().toString(); 328 } 329 // ERROR_CODE_UNKNOWN_ERROR means there is no error in PlaybackState. 330 if (state.getErrorCode() != PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR) { 331 Integer messageId = ERROR_CODE_MESSAGES_MAP.get(state.getErrorCode()); 332 return messageId != null ? getString(messageId) : getString( 333 R.string.default_error_message); 334 } 335 if (state.getState() == PlaybackStateCompat.STATE_ERROR) { 336 return getString(R.string.default_error_message); 337 } 338 return null; 339 } 340 showDialog(PendingIntent intent, String message, String positiveBtnText, String negativeButtonText)341 private void showDialog(PendingIntent intent, String message, String positiveBtnText, 342 String negativeButtonText) { 343 AlertDialogBuilder dialog = new AlertDialogBuilder(this); 344 mDialog = dialog.setMessage(message) 345 .setNegativeButton(negativeButtonText, null) 346 .setPositiveButton(positiveBtnText, (dialogInterface, i) -> { 347 try { 348 intent.send(); 349 } catch (PendingIntent.CanceledException e) { 350 if (Log.isLoggable(TAG, Log.ERROR)) { 351 Log.e(TAG, "Pending intent canceled"); 352 } 353 } 354 }) 355 .show(); 356 } 357 maybeCancelDialog()358 private void maybeCancelDialog() { 359 if (mDialog != null) { 360 mDialog.cancel(); 361 mDialog = null; 362 } 363 } 364 showToast(String message)365 private void showToast(String message) { 366 mToast = Toast.makeText(this, message, Toast.LENGTH_LONG); 367 mToast.show(); 368 } 369 maybeCancelToast()370 private void maybeCancelToast() { 371 if (mToast != null) { 372 mToast.cancel(); 373 mToast = null; 374 } 375 } 376 377 @Override onBackPressed()378 public void onBackPressed() { 379 switch (mMode) { 380 case PLAYBACK: 381 changeMode(Mode.BROWSING); 382 break; 383 case SEARCHING: 384 mSearchController.onBackPressed(); 385 break; 386 case BROWSING: 387 boolean handled = mBrowseController.onBackPressed(); 388 if (handled) return; 389 // Fall through. 390 case FATAL_ERROR: 391 default: 392 super.onBackPressed(); 393 } 394 } 395 396 /** 397 * Sets the media source being browsed. 398 * 399 * @param mediaSource the new media source we are going to try to browse 400 */ onMediaSourceChanged(@ullable MediaSource mediaSource)401 private void onMediaSourceChanged(@Nullable MediaSource mediaSource) { 402 ComponentName savedMediaSource = getInnerViewModel().getSavedMediaSource(); 403 if (Log.isLoggable(TAG, Log.INFO)) { 404 Log.i(TAG, "MediaSource changed from " + savedMediaSource + " to " + mediaSource); 405 } 406 407 savedMediaSource = mediaSource != null ? mediaSource.getBrowseServiceComponentName() : null; 408 getInnerViewModel().saveMediaSource(savedMediaSource); 409 410 mBrowseController.onMediaSourceChanged(mediaSource); 411 mSearchController.onMediaSourceChanged(mediaSource); 412 if (mErrorController != null) { 413 mErrorController.onMediaSourceChanged(mediaSource); 414 } 415 416 mCurrentPlaybackStateWrapper = null; 417 maybeCancelToast(); 418 maybeCancelDialog(); 419 if (mediaSource != null) { 420 if (Log.isLoggable(TAG, Log.INFO)) { 421 Log.i(TAG, "Browsing: " + mediaSource.getDisplayName()); 422 } 423 Mode mediaSourceMode = getInnerViewModel().getSavedMode(); 424 // Changes the mode regardless of its previous value so that the views can be updated. 425 changeModeInternal(mediaSourceMode, false); 426 427 // Always go through the trampoline activity to keep all the dispatching logic there. 428 startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE)); 429 } 430 } 431 432 @Override changeMode(Mode mode)433 public void changeMode(Mode mode) { 434 if (mMode == mode) { 435 if (Log.isLoggable(TAG, Log.INFO)) { 436 Log.i(TAG, "Mode " + mMode + " change is ignored"); 437 } 438 return; 439 } 440 changeModeInternal(mode, true); 441 } 442 changeModeInternal(Mode mode, boolean hideViewAnimated)443 private void changeModeInternal(Mode mode, boolean hideViewAnimated) { 444 if (Log.isLoggable(TAG, Log.INFO)) { 445 Log.i(TAG, "Changing mode from: " + mMode + " to: " + mode); 446 } 447 int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0; 448 449 Mode oldMode = mMode; 450 getInnerViewModel().saveMode(mode); 451 mMode = mode; 452 453 mPlaybackFragment.closeOverflowMenu(); 454 updateMiniPlaybackControls(hideViewAnimated); 455 456 switch (mMode) { 457 case FATAL_ERROR: 458 ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration); 459 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 460 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 461 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 462 break; 463 case PLAYBACK: 464 mPlaybackContainer.setX(0); 465 mPlaybackContainer.setY(0); 466 mPlaybackContainer.setAlpha(0f); 467 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 468 ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration); 469 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 470 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 471 break; 472 case BROWSING: 473 if (oldMode == Mode.PLAYBACK) { 474 ViewUtils.hideViewAnimated(mErrorContainer, 0); 475 ViewUtils.showViewAnimated(mBrowseContainer, 0); 476 ViewUtils.hideViewAnimated(mSearchContainer, 0); 477 animateOutPlaybackContainer(fadeOutDuration); 478 } else { 479 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 480 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 481 ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration); 482 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration); 483 } 484 break; 485 case SEARCHING: 486 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration); 487 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 488 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration); 489 ViewUtils.showViewAnimated(mSearchContainer, mFadeDuration); 490 break; 491 } 492 } 493 animateOutPlaybackContainer(int fadeOutDuration)494 private void animateOutPlaybackContainer(int fadeOutDuration) { 495 if (mCloseVectorNorm <= EPSILON) { 496 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 497 return; 498 } 499 500 // Assumption: mPlaybackContainer shares 1 edge with the side of the screen the 501 // slide animation brings it towards to. Since only vertical and horizontal translations 502 // are supported mPlaybackContainer only needs to move by its width or its height to be 503 // hidden. 504 505 // Use width and height with and extra pixel for safety. 506 float w = mPlaybackContainer.getWidth() + 1; 507 float h = mPlaybackContainer.getHeight() + 1; 508 509 float tX = 0.0f; 510 float tY = 0.0f; 511 if (Math.abs(mCloseVectorY) <= EPSILON) { 512 // Only moving horizontally 513 tX = mCloseVectorX * w / mCloseVectorNorm; 514 } else if (Math.abs(mCloseVectorX) <= EPSILON) { 515 // Only moving vertically 516 tY = mCloseVectorY * h / mCloseVectorNorm; 517 } else { 518 if (Log.isLoggable(TAG, Log.DEBUG)) { 519 Log.d(TAG, "The vector to close the playback container must be vertical or" 520 + " horizontal"); 521 } 522 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration); 523 return; 524 } 525 526 mPlaybackContainer.animate() 527 .translationX(tX) 528 .translationY(tY) 529 .setDuration(fadeOutDuration) 530 .setListener(ViewUtils.hideViewAfterAnimation(mPlaybackContainer)) 531 .start(); 532 } 533 updateMiniPlaybackControls(boolean hideViewAnimated)534 private void updateMiniPlaybackControls(boolean hideViewAnimated) { 535 int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0; 536 // Minimized control bar should be hidden in playback view. 537 final boolean shouldShowMiniPlaybackControls = 538 mCanShowMiniPlaybackControls && mMode != Mode.PLAYBACK; 539 if (shouldShowMiniPlaybackControls) { 540 ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration); 541 } else { 542 ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration); 543 } 544 getInnerViewModel().setMiniControlsVisible(shouldShowMiniPlaybackControls); 545 } 546 547 @Override onPlayableItemClicked(MediaItemMetadata item)548 public void onPlayableItemClicked(MediaItemMetadata item) { 549 mPlaybackController.playItem(item); 550 boolean switchToPlayback = getResources().getBoolean( 551 R.bool.switch_to_playback_view_when_playable_item_is_clicked); 552 if (switchToPlayback) { 553 changeMode(Mode.PLAYBACK); 554 } else if (mMode == Mode.SEARCHING) { 555 changeMode(Mode.BROWSING); 556 } 557 setIntent(null); 558 } 559 560 @Override onRootLoaded()561 public void onRootLoaded() { 562 PlaybackViewModel playbackViewModel = getPlaybackViewModel(); 563 handlePlaybackState(playbackViewModel.getPlaybackStateWrapper().getValue(), false); 564 } 565 566 @Override getActivity()567 public FragmentActivity getActivity() { 568 return this; 569 } 570 getMediaSourceViewModel()571 private MediaSourceViewModel getMediaSourceViewModel() { 572 return MediaSourceViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE); 573 } 574 getPlaybackViewModel()575 private PlaybackViewModel getPlaybackViewModel() { 576 return PlaybackViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE); 577 } 578 getInnerViewModel()579 private ViewModel getInnerViewModel() { 580 return ViewModelProviders.of(this).get(ViewModel.class); 581 } 582 583 public static class ViewModel extends AndroidViewModel { 584 585 static class MediaServiceState { 586 Mode mMode = Mode.BROWSING; 587 Stack<MediaItemMetadata> mBrowseStack = new Stack<>(); 588 Stack<MediaItemMetadata> mSearchStack = new Stack<>(); 589 String mSearchQuery; 590 boolean mQueueVisible = false; 591 } 592 593 private boolean mNeedsInitialization = true; 594 private PlaybackViewModel mPlaybackViewModel; 595 private ComponentName mMediaSource; 596 private final Map<ComponentName, MediaServiceState> mStates = new HashMap<>(); 597 private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>(); 598 ViewModel(@onNull Application application)599 public ViewModel(@NonNull Application application) { 600 super(application); 601 } 602 init(@onNull PlaybackViewModel playbackViewModel)603 void init(@NonNull PlaybackViewModel playbackViewModel) { 604 if (mPlaybackViewModel == playbackViewModel) { 605 return; 606 } 607 mPlaybackViewModel = playbackViewModel; 608 mNeedsInitialization = false; 609 } 610 needsInitialization()611 boolean needsInitialization() { 612 return mNeedsInitialization; 613 } 614 setMiniControlsVisible(boolean visible)615 void setMiniControlsVisible(boolean visible) { 616 mIsMiniControlsVisible.setValue(visible); 617 } 618 getMiniControlsVisible()619 LiveData<Boolean> getMiniControlsVisible() { 620 return mIsMiniControlsVisible; 621 } 622 getSavedState()623 MediaServiceState getSavedState() { 624 MediaServiceState state = mStates.get(mMediaSource); 625 if (state == null) { 626 state = new MediaServiceState(); 627 mStates.put(mMediaSource, state); 628 } 629 return state; 630 } 631 saveMode(Mode mode)632 void saveMode(Mode mode) { 633 getSavedState().mMode = mode; 634 } 635 getSavedMode()636 Mode getSavedMode() { 637 return getSavedState().mMode; 638 } 639 640 @Nullable getSelectedTab()641 MediaItemMetadata getSelectedTab() { 642 Stack<MediaItemMetadata> stack = getSavedState().mBrowseStack; 643 return (stack != null && !stack.empty()) ? stack.firstElement() : null; 644 } 645 setQueueVisible(boolean visible)646 void setQueueVisible(boolean visible) { 647 getSavedState().mQueueVisible = visible; 648 } 649 getQueueVisible()650 boolean getQueueVisible() { 651 return getSavedState().mQueueVisible; 652 } 653 saveMediaSource(ComponentName mediaSource)654 void saveMediaSource(ComponentName mediaSource) { 655 mMediaSource = mediaSource; 656 } 657 getSavedMediaSource()658 ComponentName getSavedMediaSource() { 659 return mMediaSource; 660 } 661 getBrowseStack()662 Stack<MediaItemMetadata> getBrowseStack() { 663 return getSavedState().mBrowseStack; 664 } 665 getSearchStack()666 Stack<MediaItemMetadata> getSearchStack() { 667 return getSavedState().mSearchStack; 668 } 669 getSearchQuery()670 String getSearchQuery() { 671 return getSavedState().mSearchQuery; 672 } 673 setSearchQuery(String searchQuery)674 void setSearchQuery(String searchQuery) { 675 getSavedState().mSearchQuery = searchQuery; 676 } 677 } 678 679 private class ClosePlaybackDetector extends GestureDetector.SimpleOnGestureListener 680 implements View.OnTouchListener { 681 682 private static final float COS_30 = 0.866f; 683 684 private final ViewConfiguration mViewConfig; 685 private final GestureDetectorCompat mDetector; 686 687 ClosePlaybackDetector(Context context)688 ClosePlaybackDetector(Context context) { 689 mViewConfig = ViewConfiguration.get(context); 690 mDetector = new GestureDetectorCompat(context, this); 691 } 692 693 @SuppressLint("ClickableViewAccessibility") 694 @Override onTouch(View v, MotionEvent event)695 public boolean onTouch(View v, MotionEvent event) { 696 return mDetector.onTouchEvent(event); 697 } 698 699 @Override onDown(MotionEvent event)700 public boolean onDown(MotionEvent event) { 701 return (mMode == Mode.PLAYBACK) && (mCloseVectorNorm > EPSILON); 702 } 703 704 @Override onFling(MotionEvent e1, MotionEvent e2, float vX, float vY)705 public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) { 706 float moveX = e2.getX() - e1.getX(); 707 float moveY = e2.getY() - e1.getY(); 708 float moveVectorNorm = VectorMath.norm2(moveX, moveY); 709 if (moveVectorNorm > mViewConfig.getScaledTouchSlop() && 710 VectorMath.norm2(vX, vY) > mViewConfig.getScaledMinimumFlingVelocity()) { 711 float dot = VectorMath.dotProduct(mCloseVectorX, mCloseVectorY, moveX, moveY); 712 float cos = dot / (mCloseVectorNorm * moveVectorNorm); 713 if (cos >= COS_30) { // Accept 30 degrees on each side of the close vector. 714 changeMode(Mode.BROWSING); 715 } 716 } 717 return true; 718 } 719 } 720 } 721