blob: 591285a41e7baf74c7bec6985b92ade64321a97f [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.media;
import static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_BROWSE;
import static com.android.car.apps.common.util.LiveDataFunctions.dataOf;
import static com.android.car.apps.common.util.VectorMath.EPSILON;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.Application;
import android.app.PendingIntent;
import android.car.Car;
import android.car.content.pm.CarPackageManager;
import android.car.drivingstate.CarUxRestrictions;
import android.car.media.CarMediaIntents;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.util.Size;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.GestureDetectorCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModelProviders;
import com.android.car.apps.common.util.CarPackageManagerUtils;
import com.android.car.apps.common.util.FutureData;
import com.android.car.apps.common.util.VectorMath;
import com.android.car.apps.common.util.ViewUtils;
import com.android.car.media.common.MediaItemMetadata;
import com.android.car.media.common.MinimizedPlaybackControlBar;
import com.android.car.media.common.PlaybackErrorsHelper;
import com.android.car.media.common.browse.MediaItemsRepository;
import com.android.car.media.common.playback.PlaybackViewModel;
import com.android.car.media.common.source.MediaSource;
import com.android.car.media.common.source.MediaTrampolineHelper;
import com.android.car.ui.AlertDialogBuilder;
import com.android.car.ui.utils.CarUxRestrictionsUtil;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Stack;
/**
* This activity controls the UI of media. It also updates the connection status for the media app
* by broadcast.
*/
public class MediaActivity extends FragmentActivity implements MediaActivityController.Callbacks {
private static final String TAG = "MediaActivity";
/** Configuration (controlled from resources) */
private int mFadeDuration;
/** Models */
private PlaybackViewModel.PlaybackController mPlaybackController;
/** Layout views */
private PlaybackFragment mPlaybackFragment;
private MediaActivityController mMediaActivityController;
private MinimizedPlaybackControlBar mMiniPlaybackControls;
private ViewGroup mBrowseContainer;
private ViewGroup mPlaybackContainer;
private ViewGroup mErrorContainer;
private ErrorScreenController mErrorController;
private Toast mToast;
private AlertDialog mDialog;
/** Current state */
private Mode mMode;
private boolean mCanShowMiniPlaybackControls;
private PlaybackViewModel.PlaybackStateWrapper mCurrentPlaybackStateWrapper;
private Car mCar;
private CarPackageManager mCarPackageManager;
private float mCloseVectorX;
private float mCloseVectorY;
private float mCloseVectorNorm;
private PlaybackFragment.PlaybackFragmentListener mPlaybackFragmentListener =
() -> changeMode(Mode.BROWSING);
private MediaTrampolineHelper mMediaTrampoline;
/**
* Possible modes of the application UI
* Todo: refactor into non exclusive flags to allow concurrent modes (eg: play details & browse)
* (b/179292793).
*/
enum Mode {
/** The user is browsing or searching a media source */
BROWSING,
/** The user is interacting with the full screen playback UI */
PLAYBACK,
/** There's no browse tree and playback doesn't work. */
FATAL_ERROR
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.media_activity);
Resources res = getResources();
mCloseVectorX = res.getFloat(R.dimen.media_activity_close_vector_x);
mCloseVectorY = res.getFloat(R.dimen.media_activity_close_vector_y);
mCloseVectorNorm = VectorMath.norm2(mCloseVectorX, mCloseVectorY);
// TODO(b/151174811): Use appropriate modes, instead of just MEDIA_SOURCE_MODE_BROWSE
PlaybackViewModel playbackViewModel = getPlaybackViewModel();
ViewModel localViewModel = getInnerViewModel();
// We can't rely on savedInstanceState to determine whether the model has been initialized
// as on a config change savedInstanceState != null and the model is initialized, but if
// the app was killed by the system then savedInstanceState != null and the model is NOT
// initialized...
if (localViewModel.needsInitialization()) {
localViewModel.init(playbackViewModel);
}
mMode = localViewModel.getSavedMode();
localViewModel.getBrowsedMediaSource().observe(this, this::onMediaSourceChanged);
mMediaTrampoline = new MediaTrampolineHelper(this);
mPlaybackFragment = new PlaybackFragment();
mPlaybackFragment.setListener(mPlaybackFragmentListener);
Size maxArtSize = MediaAppConfig.getMediaItemsBitmapMaxSize(this);
mMiniPlaybackControls = findViewById(R.id.minimized_playback_controls);
mMiniPlaybackControls.setModel(playbackViewModel, this, maxArtSize);
mMiniPlaybackControls.setOnClickListener(view -> changeMode(Mode.PLAYBACK));
mFadeDuration = res.getInteger(R.integer.new_album_art_fade_in_duration);
mBrowseContainer = findViewById(R.id.fragment_container);
mErrorContainer = findViewById(R.id.error_container);
mPlaybackContainer = findViewById(R.id.playback_container);
getSupportFragmentManager().beginTransaction()
.replace(R.id.playback_container, mPlaybackFragment)
.commit();
playbackViewModel.getPlaybackController().observe(this,
playbackController -> {
if (playbackController != null) playbackController.prepare();
mPlaybackController = playbackController;
});
playbackViewModel.getPlaybackStateWrapper().observe(this,
state -> handlePlaybackState(state, true));
mCar = Car.createCar(this);
mCarPackageManager = (CarPackageManager) mCar.getCarManager(Car.PACKAGE_SERVICE);
mMediaActivityController = new MediaActivityController(this, getMediaItemsRepository(),
mCarPackageManager, mBrowseContainer);
mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this));
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent); // getIntent() should always return the most recent
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onNewIntent: " + intent);
}
}
@Override
protected void onResume() {
super.onResume();
Intent intent = getIntent();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onResume intent: " + intent);
}
if (intent != null) {
String compName = getIntent().getStringExtra(CarMediaIntents.EXTRA_MEDIA_COMPONENT);
ComponentName launchedSourceComp = (compName == null) ? null :
ComponentName.unflattenFromString(compName);
if (launchedSourceComp == null) {
// Might happen if there's no media source at all on the system as the
// MediaDispatcherActivity always specifies the component otherwise.
Log.w(TAG, "launchedSourceComp should almost never be null: " + compName);
}
mMediaTrampoline.setLaunchedMediaSource(launchedSourceComp);
// Mark the intent as consumed so that coming back from the media app selector doesn't
// set the source again.
setIntent(null);
}
}
@Override
protected void onDestroy() {
mCar.disconnect();
mMediaActivityController.onDestroy();
super.onDestroy();
}
private boolean isUxRestricted() {
return CarUxRestrictionsUtil.isRestricted(CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP,
CarUxRestrictionsUtil.getInstance(this).getCurrentRestrictions());
}
private void handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state,
boolean ignoreSameState) {
mErrorsHelper.handlePlaybackState(TAG, state, ignoreSameState);
}
private final PlaybackErrorsHelper mErrorsHelper = new PlaybackErrorsHelper(this) {
@Override
public void handlePlaybackState(@NonNull String tag,
PlaybackViewModel.PlaybackStateWrapper state, boolean ignoreSameState) {
// TODO rethink interactions between customized layouts and dynamic visibility.
mCanShowMiniPlaybackControls = (state != null) && state.shouldDisplay();
updateMiniPlaybackControls(true);
super.handlePlaybackState(tag, state, ignoreSameState);
}
@Override
public void handleNewPlaybackState(String displayedMessage, PendingIntent intent,
String label) {
maybeCancelToast();
maybeCancelDialog();
boolean isFatalError = false;
if (!TextUtils.isEmpty(displayedMessage)) {
if (mMediaActivityController.browseTreeHasChildren()) {
if (intent != null && !isUxRestricted()) {
showDialog(intent, displayedMessage, label,
getString(android.R.string.cancel));
} else {
showToast(displayedMessage);
}
} else {
boolean isDistractionOptimized =
intent != null && CarPackageManagerUtils.isDistractionOptimized(
mCarPackageManager, intent);
getErrorController().setError(displayedMessage, label, intent,
isDistractionOptimized);
isFatalError = true;
}
}
if (isFatalError) {
changeMode(MediaActivity.Mode.FATAL_ERROR);
} else if (mMode == MediaActivity.Mode.FATAL_ERROR) {
changeMode(MediaActivity.Mode.BROWSING);
}
}
};
private ErrorScreenController getErrorController() {
if (mErrorController == null) {
mErrorController = new ErrorScreenController(this, mCarPackageManager, mErrorContainer);
MediaSource mediaSource = getInnerViewModel().getMediaSourceValue();
mErrorController.onMediaSourceChanged(mediaSource);
}
return mErrorController;
}
private void showDialog(PendingIntent intent, String message, String positiveBtnText,
String negativeButtonText) {
AlertDialogBuilder dialog = new AlertDialogBuilder(this);
mDialog = dialog.setMessage(message)
.setNegativeButton(negativeButtonText, null)
.setPositiveButton(positiveBtnText, (dialogInterface, i) -> {
try {
intent.send();
} catch (PendingIntent.CanceledException e) {
if (Log.isLoggable(TAG, Log.ERROR)) {
Log.e(TAG, "Pending intent canceled");
}
}
})
.show();
}
private void maybeCancelDialog() {
if (mDialog != null) {
mDialog.cancel();
mDialog = null;
}
}
private void showToast(String message) {
mToast = Toast.makeText(this, message, Toast.LENGTH_LONG);
mToast.show();
}
private void maybeCancelToast() {
if (mToast != null) {
mToast.cancel();
mToast = null;
}
}
@Override
public void onBackPressed() {
switch (mMode) {
case PLAYBACK:
changeMode(Mode.BROWSING);
break;
case BROWSING:
boolean handled = mMediaActivityController.onBackPressed();
if (handled) return;
// Fall through.
case FATAL_ERROR:
default:
super.onBackPressed();
}
}
/**
* Sets the media source being browsed.
*
* @param futureSource contains the new media source we are going to try to browse, as well as
* the old one (either could be null).
*/
private void onMediaSourceChanged(FutureData<MediaSource> futureSource) {
MediaSource newMediaSource = FutureData.getData(futureSource);
MediaSource oldMediaSource = FutureData.getPastData(futureSource);
if (mErrorController != null) {
mErrorController.onMediaSourceChanged(newMediaSource);
}
mCurrentPlaybackStateWrapper = null;
maybeCancelToast();
maybeCancelDialog();
if (newMediaSource != null) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Browsing: " + newMediaSource.getDisplayName());
}
if (Objects.equals(oldMediaSource, newMediaSource)) {
// The UI is being restored (eg: after a config change) => restore the mode.
Mode mediaSourceMode = getInnerViewModel().getSavedMode();
changeModeInternal(mediaSourceMode, false);
} else {
// Change the mode regardless of its previous value to update the views.
// The saved mode is ignored as the media apps don't always recreate a playback
// state that can be displayed (and some send a displayable state after sending a
// non displayable one...).
changeModeInternal(Mode.BROWSING, false);
}
}
}
private void changeMode(Mode mode) {
if (mMode == mode) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Mode " + mMode + " change is ignored");
}
return;
}
changeModeInternal(mode, true);
}
private void changeModeInternal(Mode mode, boolean hideViewAnimated) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Changing mode from: " + mMode + " to: " + mode);
}
int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
Mode oldMode = mMode;
getInnerViewModel().saveMode(mode);
mMode = mode;
mPlaybackFragment.closeOverflowMenu();
updateMiniPlaybackControls(hideViewAnimated);
switch (mMode) {
case FATAL_ERROR:
ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration);
ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
break;
case PLAYBACK:
mPlaybackContainer.setX(0);
mPlaybackContainer.setY(0);
mPlaybackContainer.setAlpha(0f);
ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration);
ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
break;
case BROWSING:
if (oldMode == Mode.PLAYBACK) {
ViewUtils.hideViewAnimated(mErrorContainer, 0);
ViewUtils.showViewAnimated(mBrowseContainer, 0);
animateOutPlaybackContainer(fadeOutDuration);
} else {
ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration);
}
break;
}
}
private void animateOutPlaybackContainer(int fadeOutDuration) {
if (mCloseVectorNorm <= EPSILON) {
ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
return;
}
// Assumption: mPlaybackContainer shares 1 edge with the side of the screen the
// slide animation brings it towards to. Since only vertical and horizontal translations
// are supported mPlaybackContainer only needs to move by its width or its height to be
// hidden.
// Use width and height with and extra pixel for safety.
float w = mPlaybackContainer.getWidth() + 1;
float h = mPlaybackContainer.getHeight() + 1;
float tX = 0.0f;
float tY = 0.0f;
if (Math.abs(mCloseVectorY) <= EPSILON) {
// Only moving horizontally
tX = mCloseVectorX * w / mCloseVectorNorm;
} else if (Math.abs(mCloseVectorX) <= EPSILON) {
// Only moving vertically
tY = mCloseVectorY * h / mCloseVectorNorm;
} else {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "The vector to close the playback container must be vertical or"
+ " horizontal");
}
ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
return;
}
mPlaybackContainer.animate()
.translationX(tX)
.translationY(tY)
.setDuration(fadeOutDuration)
.setListener(ViewUtils.hideViewAfterAnimation(mPlaybackContainer))
.start();
}
private void updateMiniPlaybackControls(boolean hideViewAnimated) {
int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
// Minimized control bar should be hidden in playback view.
final boolean shouldShowMiniPlaybackControls =
getResources().getBoolean(R.bool.show_mini_playback_controls)
&& mCanShowMiniPlaybackControls
&& mMode != Mode.PLAYBACK;
if (shouldShowMiniPlaybackControls) {
Boolean visible = getInnerViewModel().getMiniControlsVisible().getValue();
if (visible != Boolean.TRUE) {
ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration);
}
} else {
ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration);
}
getInnerViewModel().setMiniControlsVisible(shouldShowMiniPlaybackControls);
}
@Override
public void onPlayableItemClicked(@NonNull MediaItemMetadata item) {
mPlaybackController.playItem(item);
boolean switchToPlayback = getResources().getBoolean(
R.bool.switch_to_playback_view_when_playable_item_is_clicked);
if (switchToPlayback) {
changeMode(Mode.PLAYBACK);
}
setIntent(null);
}
@Override
public void onRootLoaded() {
PlaybackViewModel playbackViewModel = getPlaybackViewModel();
handlePlaybackState(playbackViewModel.getPlaybackStateWrapper().getValue(), false);
}
@Override
public FragmentActivity getActivity() {
return this;
}
private MediaItemsRepository getMediaItemsRepository() {
return MediaItemsRepository.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
}
private PlaybackViewModel getPlaybackViewModel() {
return PlaybackViewModel.get(getApplication(), MEDIA_SOURCE_MODE_BROWSE);
}
private ViewModel getInnerViewModel() {
return ViewModelProviders.of(this).get(ViewModel.class);
}
public static class ViewModel extends AndroidViewModel {
static class MediaServiceState {
Mode mMode = Mode.BROWSING;
Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
Stack<MediaItemMetadata> mSearchStack = new Stack<>();
/** True when the search bar has been opened or when the search results are browsed. */
boolean mSearching;
/** True iif the list of search results is being shown (implies mIsSearching). */
boolean mShowingSearchResults;
String mSearchQuery;
boolean mQueueVisible = false;
}
private boolean mNeedsInitialization = true;
private PlaybackViewModel mPlaybackViewModel;
private final MutableLiveData<FutureData<MediaSource>> mBrowsedMediaSource =
dataOf(FutureData.newLoadingData());
private final Map<MediaSource, MediaServiceState> mStates = new HashMap<>();
private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>();
public ViewModel(@NonNull Application application) {
super(application);
}
void init(@NonNull PlaybackViewModel playbackViewModel) {
if (mPlaybackViewModel == playbackViewModel) {
return;
}
mPlaybackViewModel = playbackViewModel;
mNeedsInitialization = false;
}
boolean needsInitialization() {
return mNeedsInitialization;
}
void setMiniControlsVisible(boolean visible) {
mIsMiniControlsVisible.setValue(visible);
}
LiveData<Boolean> getMiniControlsVisible() {
return mIsMiniControlsVisible;
}
@Nullable
MediaSource getMediaSourceValue() {
return FutureData.getData(mBrowsedMediaSource.getValue());
}
MediaServiceState getSavedState() {
MediaSource source = getMediaSourceValue();
MediaServiceState state = mStates.get(source);
if (state == null) {
state = new MediaServiceState();
mStates.put(source, state);
}
return state;
}
void saveMode(Mode mode) {
getSavedState().mMode = mode;
}
Mode getSavedMode() {
return getSavedState().mMode;
}
@Nullable
MediaItemMetadata getSelectedTab() {
Stack<MediaItemMetadata> stack = getSavedState().mBrowseStack;
return (stack != null && !stack.empty()) ? stack.firstElement() : null;
}
void setQueueVisible(boolean visible) {
getSavedState().mQueueVisible = visible;
}
boolean getQueueVisible() {
return getSavedState().mQueueVisible;
}
void saveBrowsedMediaSource(MediaSource mediaSource) {
Resources res = getApplication().getResources();
if (MediaDispatcherActivity.isCustomMediaSource(res, mediaSource)) {
Log.i(TAG, "Ignoring custom media source: " + mediaSource);
return;
}
MediaSource oldSource = getMediaSourceValue();
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "MediaSource changed from " + oldSource + " to " + mediaSource);
}
mBrowsedMediaSource.setValue(FutureData.newLoadedData(oldSource, mediaSource));
}
LiveData<FutureData<MediaSource>> getBrowsedMediaSource() {
return mBrowsedMediaSource;
}
@NonNull Stack<MediaItemMetadata> getBrowseStack() {
return getSavedState().mBrowseStack;
}
@NonNull Stack<MediaItemMetadata> getSearchStack() {
return getSavedState().mSearchStack;
}
/** Returns whether search mode is on (showing search results or browsing them). */
boolean isSearching() {
return getSavedState().mSearching;
}
boolean isShowingSearchResults() {
return getSavedState().mShowingSearchResults;
}
String getSearchQuery() {
return getSavedState().mSearchQuery;
}
void setSearching(boolean isSearching) {
getSavedState().mSearching = isSearching;
}
void setShowingSearchResults(boolean isShowing) {
getSavedState().mShowingSearchResults = isShowing;
}
void setSearchQuery(String searchQuery) {
getSavedState().mSearchQuery = searchQuery;
}
}
private class ClosePlaybackDetector extends GestureDetector.SimpleOnGestureListener
implements View.OnTouchListener {
private static final float COS_30 = 0.866f;
private final ViewConfiguration mViewConfig;
private final GestureDetectorCompat mDetector;
ClosePlaybackDetector(Context context) {
mViewConfig = ViewConfiguration.get(context);
mDetector = new GestureDetectorCompat(context, this);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouch(View v, MotionEvent event) {
return mDetector.onTouchEvent(event);
}
@Override
public boolean onDown(MotionEvent event) {
return (mMode == Mode.PLAYBACK) && (mCloseVectorNorm > EPSILON);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) {
float moveX = e2.getX() - e1.getX();
float moveY = e2.getY() - e1.getY();
float moveVectorNorm = VectorMath.norm2(moveX, moveY);
if (moveVectorNorm > mViewConfig.getScaledTouchSlop() &&
VectorMath.norm2(vX, vY) > mViewConfig.getScaledMinimumFlingVelocity()) {
float dot = VectorMath.dotProduct(mCloseVectorX, mCloseVectorY, moveX, moveY);
float cos = dot / (mCloseVectorNorm * moveVectorNorm);
if (cos >= COS_30) { // Accept 30 degrees on each side of the close vector.
changeMode(Mode.BROWSING);
}
}
return true;
}
}
}