blob: 09861cbe045ef92bab79a6495fba0deb179ebedb [file] [log] [blame]
/*
* Copyright 2018 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.common.playback;
import static androidx.lifecycle.Transformations.map;
import static androidx.lifecycle.Transformations.switchMap;
import static com.android.car.arch.common.LiveDataFunctions.dataOf;
import static com.android.car.arch.common.LiveDataFunctions.nullLiveData;
import static com.android.car.arch.common.LiveDataFunctions.pair;
import static com.android.car.media.common.playback.PlaybackStateAnnotations.Actions;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Application;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.media.MediaMetadata;
import android.media.Rating;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import com.android.car.media.common.CustomPlaybackAction;
import com.android.car.media.common.MediaItemMetadata;
import com.android.car.media.common.R;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* ViewModel for media playback.
* <p>
* Observes changes to the provided MediaController to expose playback state and metadata
* observables. Clients should ensure {@link #setMediaController(LiveData)} has been called before
* calling any other methods. {@link #setMediaController(LiveData)} may be called multiple times,
* but it is expected that clients will only call it once on startup.
*/
public class PlaybackViewModel extends AndroidViewModel {
private static final String TAG = "PlaybackViewModel";
private static final String ACTION_SET_RATING =
"com.android.car.media.common.ACTION_SET_RATING";
private static final String EXTRA_SET_HEART = "com.android.car.media.common.EXTRA_SET_HEART";
/**
* Possible main actions.
*/
@IntDef({ACTION_PLAY, ACTION_STOP, ACTION_PAUSE, ACTION_DISABLED})
@Retention(RetentionPolicy.SOURCE)
public @interface Action {
}
/**
* Main action is disabled. The source can't play media at this time
*/
public static final int ACTION_DISABLED = 0;
/**
* Start playing
*/
public static final int ACTION_PLAY = 1;
/**
* Stop playing
*/
public static final int ACTION_STOP = 2;
/**
* Pause playing
*/
public static final int ACTION_PAUSE = 3;
public PlaybackViewModel(Application application) {
super(application);
}
private final SwitchingLiveData<MediaController> mMediaController = new SwitchingLiveData<>();
private final LiveData<MediaMetadata> mMetadata = switchMap(mMediaController,
mediaController -> mediaController == null ? nullLiveData()
: new MediaMetadataLiveData(mediaController));
private final LiveData<MediaItemMetadata> mWrappedMetadata = map(mMetadata,
MediaItemMetadata::new);
private final LiveData<PlaybackState> mPlaybackState = switchMap(mMediaController,
mediaController -> mediaController == null ? nullLiveData()
: new PlaybackStateLiveData(mediaController));
private final LiveData<List<MediaSession.QueueItem>> mQueue = switchMap(mMediaController,
mediaController -> mediaController == null ? nullLiveData()
: new QueueLiveData(mediaController));
// Filters out queue items with no description or title and converts them to MediaItemMetadatas
private final LiveData<List<MediaItemMetadata>> mSanitizedQueue = map(mQueue,
queue -> queue == null ? Collections.emptyList()
: queue.stream()
.filter(item -> item.getDescription() != null
&& item.getDescription().getTitle() != null)
.map(MediaItemMetadata::new)
.collect(Collectors.toList()));
private final LiveData<Boolean> mHasQueue = map(mQueue,
queue -> queue != null && !queue.isEmpty());
private final LiveData<PlaybackController> mPlaybackControls = map(mMediaController,
PlaybackController::new);
private final LiveData<CombinedInfo> mCombinedInfo =
map(
pair(mMediaController, pair(mMetadata, mPlaybackState)),
input ->
new CombinedInfo(input.first, input.second.first, input.second.second));
private final PlaybackInfo mPlaybackInfo = new PlaybackInfo();
/**
* Sets the MediaController source for this ViewModel. This should be called before other
* methods on this ViewModel are set. This method may be called multiple times, and the
* LiveDatas returned by other methods in this ViewModel will refer to the latest
* MediaController.
*/
public void setMediaController(@Nullable LiveData<MediaController> mediaControllerData) {
mMediaController.setSource(mediaControllerData);
}
/**
* Returns a LiveData that emits the currently set MediaController (may emit null).
*/
@NonNull
public LiveData<MediaController> getMediaController() {
return mMediaController;
}
/**
* Returns a LiveData that emits a MediaItemMetadata of the current media item in the session
* managed by the provided {@link MediaController}.
*/
public LiveData<MediaItemMetadata> getMetadata() {
return mWrappedMetadata;
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
LiveData<PlaybackState> getPlaybackState() {
return mPlaybackState;
}
/**
* Returns a LiveData that emits the current queue as MediaItemMetadatas where items without a
* title have been filtered out.
*/
public LiveData<List<MediaItemMetadata>> getQueue() {
return mSanitizedQueue;
}
/**
* Returns a LiveData that emits whether the MediaController has a non-empty queue
*/
public LiveData<Boolean> hasQueue() {
return mHasQueue;
}
/**
* Returns a LiveData that emits an object for controlling the currently selected
* MediaController.
*/
public LiveData<PlaybackController> getPlaybackController() {
return mPlaybackControls;
}
/**
* Returns an object for accessing data dependent on the current playback state.
*/
public PlaybackInfo getPlaybackInfo() {
return mPlaybackInfo;
}
/**
* Contains LiveDatas related to the current PlaybackState. A single instance of this object
* is created for each PlaybackViewModel.
*/
public class PlaybackInfo {
private LiveData<Integer> mMainAction = map(mPlaybackState, state -> {
if (state == null) {
return ACTION_DISABLED;
}
@Actions long actions = state.getActions();
@Action int stopAction = ACTION_DISABLED;
if ((actions & (PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_PLAY_PAUSE)) != 0) {
stopAction = ACTION_PAUSE;
} else if ((actions & PlaybackState.ACTION_STOP) != 0) {
stopAction = ACTION_STOP;
}
switch (state.getState()) {
case PlaybackState.STATE_PLAYING:
case PlaybackState.STATE_BUFFERING:
case PlaybackState.STATE_CONNECTING:
case PlaybackState.STATE_FAST_FORWARDING:
case PlaybackState.STATE_REWINDING:
case PlaybackState.STATE_SKIPPING_TO_NEXT:
case PlaybackState.STATE_SKIPPING_TO_PREVIOUS:
case PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM:
return stopAction;
case PlaybackState.STATE_STOPPED:
case PlaybackState.STATE_PAUSED:
case PlaybackState.STATE_NONE:
return ACTION_PLAY;
case PlaybackState.STATE_ERROR:
return ACTION_DISABLED;
default:
Log.w(TAG, String.format("Unknown PlaybackState: %d", state.getState()));
return ACTION_DISABLED;
}
});
private final LiveData<Long> mMaxProgress = map(mMetadata,
metadata -> metadata == null ? 0
: metadata.getLong(MediaMetadata.METADATA_KEY_DURATION));
private final LiveData<Long> mProgress =
switchMap(pair(mPlaybackState, mMaxProgress),
pair -> pair.first == null ? dataOf(0L)
: new ProgressLiveData(pair.first, pair.second));
private final LiveData<Boolean> mIsPlaying = map(mPlaybackState,
state -> state != null && state.getState() == PlaybackState.STATE_PLAYING);
private final LiveData<Boolean> mIsSkipNextEnabled = map(mPlaybackState,
state -> state != null
&& (state.getActions() & PlaybackState.ACTION_SKIP_TO_NEXT) != 0);
private final LiveData<Boolean> mIsSkipPreviousEnabled = map(mPlaybackState,
state -> state != null
&& (state.getActions() & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0);
private final LiveData<Boolean> mIsBuffering = map(mPlaybackState,
state -> state != null && state.getState() == PlaybackState.STATE_BUFFERING);
private final LiveData<CharSequence> mErrorMessage = map(mPlaybackState,
state -> state == null ? null : state.getErrorMessage());
private final LiveData<Long> mActiveQueueItemId = map(mPlaybackState,
state -> state == null ? MediaSession.QueueItem.UNKNOWN_ID
: state.getActiveQueueItemId());
private final LiveData<List<RawCustomPlaybackAction>> mCustomActions = map(mCombinedInfo,
this::getCustomActions);
private PlaybackInfo() {
}
@Action
public LiveData<Integer> getMainAction() {
return mMainAction;
}
/**
* Returns a sorted list of custom actions available. Call {@link #fetchDrawable(Context,
* RawCustomPlaybackAction)} to get the appropriate icon Drawable.
*/
public LiveData<List<RawCustomPlaybackAction>> getCustomActions() {
return mCustomActions;
}
/**
* Converts a {@link RawCustomPlaybackAction} into a {@link CustomPlaybackAction} by
* fetching the appropriate drawable for the icon.
*
* @param context Context into which the icon will be drawn
* @param action RawCustomPlaybackAction that contains info to convert
* @return the converted CustomPlaybackAction
*/
public CustomPlaybackAction fetchDrawable(@NonNull Context context,
@NonNull RawCustomPlaybackAction action) {
Drawable icon;
if (action.mPackageName == null) {
icon = context.getDrawable(action.mIcon);
} else {
Resources resources = getResourcesForPackage(context, action.mPackageName);
if (resources == null) {
icon = null;
} else {
// the resources may be from another package. we need to update the
// configuration
// using the context from the activity so we get the drawable from the
// correct DPI
// bucket.
resources.updateConfiguration(context.getResources().getConfiguration(),
context.getResources().getDisplayMetrics());
icon = resources.getDrawable(action.mIcon, null);
}
}
return new CustomPlaybackAction(icon, action.mAction, action.mExtras);
}
/**
* Returns a LiveData that emits the duration of the media item, in milliseconds. The
* current position in this duration can be obtained by calling {@link #getProgress()}.
*/
public LiveData<Long> getMaxProgress() {
return mMaxProgress;
}
/**
* Returns a LiveData that emits the current playback progress, in milliseconds. This is a
* value between 0 and {@link #getMaxProgress()} or
* {@link PlaybackState#PLAYBACK_POSITION_UNKNOWN}
* if the current position is unknown. This value will update on its own periodically (less
* than a second) while active.
*/
public LiveData<Long> getProgress() {
return mProgress;
}
/**
* Returns a LiveData that emits {@code true} iff the current media source is playing a
* media item.
*/
public LiveData<Boolean> isPlaying() {
return mIsPlaying;
}
/**
* Returns a LiveData that emits {@code true} iff the media source supports skipping to the
* next item.
*/
public LiveData<Boolean> isSkipNextEnabled() {
return mIsSkipNextEnabled;
}
/**
* Returns a LiveData that emits {@code true} iff the media source supports skipping to the
* previous item.
*/
public LiveData<Boolean> isSkipPreviousEnabled() {
return mIsSkipPreviousEnabled;
}
/**
* Returns a LiveData that emits {@code true} iff the media source is buffering
*/
public LiveData<Boolean> isBuffering() {
return mIsBuffering;
}
/**
* Returns a LiveData that emits a human readable description of the error that cause the
* media source to be in a non-playable state, or {@code null} if there is no error.
*/
public LiveData<CharSequence> getErrorMessage() {
return mErrorMessage;
}
/**
* Returns a LiveData that emits the queue id of the currently playing queue item, or
* {@link MediaSession.QueueItem#UNKNOWN_ID} if none of the items is currently playing.
*/
public LiveData<Long> getActiveQueueItemId() {
return mActiveQueueItemId;
}
private List<RawCustomPlaybackAction> getCustomActions(
@Nullable CombinedInfo info) {
PlaybackState playbackState = info.mPlaybackState;
List<RawCustomPlaybackAction> actions = new ArrayList<>();
if (playbackState == null) return actions;
RawCustomPlaybackAction ratingAction = getRatingAction(info);
if (ratingAction != null) actions.add(ratingAction);
for (PlaybackState.CustomAction action : playbackState.getCustomActions()) {
String packageName = info.mMediaController.getPackageName();
actions.add(
new RawCustomPlaybackAction(action.getIcon(), packageName,
action.getAction(),
action.getExtras()));
}
return actions;
}
@Nullable
private RawCustomPlaybackAction getRatingAction(@Nullable CombinedInfo info) {
PlaybackState playbackState = info.mPlaybackState;
if (playbackState == null) return null;
long stdActions = playbackState.getActions();
if ((stdActions & PlaybackState.ACTION_SET_RATING) == 0) return null;
int ratingType = info.mMediaController.getRatingType();
if (ratingType != Rating.RATING_HEART) return null;
MediaMetadata metadata = info.mMetadata;
boolean hasHeart = false;
if (metadata != null) {
Rating rating = metadata.getRating(MediaMetadata.METADATA_KEY_USER_RATING);
hasHeart = rating != null && rating.hasHeart();
}
int iconResource = hasHeart ? R.drawable.ic_star_filled : R.drawable.ic_star_empty;
Bundle extras = new Bundle();
extras.putBoolean(EXTRA_SET_HEART, !hasHeart);
return new RawCustomPlaybackAction(iconResource, null, ACTION_SET_RATING,
extras);
}
private Resources getResourcesForPackage(Context context, String packageName) {
try {
return context.getPackageManager().getResourcesForApplication(packageName);
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Unable to get resources for " + packageName);
return null;
}
}
}
/**
* Wraps the {@link android.media.session.MediaController.TransportControls TransportControls}
* for a {@link MediaController} to send commands.
*/
public static class PlaybackController {
private final MediaController mMediaController;
private PlaybackController(@Nullable MediaController mediaController) {
mMediaController = mediaController;
}
/**
* Sends a 'play' command to the media source
*/
public void play() {
if (mMediaController != null) {
mMediaController.getTransportControls().play();
}
}
/**
* Sends a 'skip previews' command to the media source
*/
public void skipToPrevious() {
if (mMediaController != null) {
mMediaController.getTransportControls().skipToPrevious();
}
}
/**
* Sends a 'skip next' command to the media source
*/
public void skipToNext() {
if (mMediaController != null) {
mMediaController.getTransportControls().skipToNext();
}
}
/**
* Sends a 'pause' command to the media source
*/
public void pause() {
if (mMediaController != null) {
mMediaController.getTransportControls().pause();
}
}
/**
* Sends a 'stop' command to the media source
*/
public void stop() {
if (mMediaController != null) {
mMediaController.getTransportControls().stop();
}
}
/**
* Sends a custom action to the media source
*
* @param action identifier of the custom action
* @param extras additional data to send to the media source.
*/
public void doCustomAction(String action, Bundle extras) {
if (mMediaController == null) return;
MediaController.TransportControls cntrl = mMediaController.getTransportControls();
if (ACTION_SET_RATING.equals(action)) {
boolean setHeart = extras != null && extras.getBoolean(EXTRA_SET_HEART, false);
cntrl.setRating(Rating.newHeartRating(setHeart));
} else {
cntrl.sendCustomAction(action, extras);
}
}
/**
* Starts playing a given media item. This id corresponds to {@link
* MediaItemMetadata#getId()}.
*/
public void playItem(String mediaItemId) {
if (mMediaController != null) {
mMediaController.getTransportControls().playFromMediaId(mediaItemId, null);
}
}
/**
* Skips to a particular item in the media queue. This id is {@link
* MediaItemMetadata#mQueueId} of the items obtained through {@link
* PlaybackViewModel#getQueue()}.
*/
public void skipToQueueItem(long queueId) {
if (mMediaController != null) {
mMediaController.getTransportControls().skipToQueueItem(queueId);
}
}
/**
* Prepares the current media source for playback.
*/
public void prepare() {
if (mMediaController != null) {
mMediaController.getTransportControls().prepare();
}
}
}
/**
* Abstract representation of a custom playback action. A custom playback action represents a
* visual element that can be used to trigger playback actions not included in the standard
* {@link PlaybackController} class. Custom actions for the current media source are exposed
* through {@link PlaybackInfo#getCustomActions()}
* <p>
* Does not contain a {@link Drawable} representation of the icon. Instances of
* this object should be converted to a {@link CustomPlaybackAction} via {@link
* PlaybackInfo#fetchDrawable(Context, RawCustomPlaybackAction)} for display.
*/
public static class RawCustomPlaybackAction {
// TODO (keyboardr): This class (and associtated translation code) will be merged with
// CustomPlaybackAction in a future CL.
/**
* Icon to display for this custom action
*/
public final int mIcon;
/**
* If true, use the resources from the this package to resolve the icon. If null use our own
* resources.
*/
@Nullable
public final String mPackageName;
/**
* Action identifier used to request this action to the media service
*/
@NonNull
public final String mAction;
/**
* Any additional information to send along with the action identifier
*/
@Nullable
public final Bundle mExtras;
/**
* Creates a custom action
*/
public RawCustomPlaybackAction(int icon, String packageName,
@NonNull String action,
@Nullable Bundle extras) {
mIcon = icon;
mPackageName = packageName;
mAction = action;
mExtras = extras;
}
}
static class CombinedInfo {
final MediaController mMediaController;
final MediaMetadata mMetadata;
final PlaybackState mPlaybackState;
private CombinedInfo(MediaController mediaController,
MediaMetadata metadata, PlaybackState playbackState) {
this.mMediaController = mediaController;
this.mMetadata = metadata;
this.mPlaybackState = playbackState;
}
}
private class SwitchingLiveData<T> extends MediatorLiveData<T> {
private LiveData<T> mCurrentSource;
public void setSource(@Nullable LiveData<T> source) {
if (source == mCurrentSource) {
return;
}
if (mCurrentSource != null) {
removeSource(mCurrentSource);
}
mCurrentSource = source;
if (source != null) {
addSource(source, this::setValue);
} else {
setValue(null);
}
}
}
}