blob: 3df5653823ba6b3821368e3973ab0cb2497721d3 [file] [log] [blame]
/*
* Copyright 2019 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.testmediaapp;
import static android.media.AudioManager.AUDIOFOCUS_GAIN;
import static android.media.AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SEEK_TO;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM;
import static android.support.v4.media.session.PlaybackStateCompat.ERROR_CODE_APP_ERROR;
import static android.support.v4.media.session.PlaybackStateCompat.STATE_ERROR;
import static com.android.car.media.common.MediaConstants.ERROR_RESOLUTION_ACTION_INTENT;
import static com.android.car.media.common.MediaConstants.ERROR_RESOLUTION_ACTION_LABEL;
import androidx.annotation.Nullable;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.os.Bundle;
import android.os.Handler;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.util.Log;
import android.widget.Toast;
import com.android.car.media.testmediaapp.TmaMediaEvent.Action;
import com.android.car.media.testmediaapp.TmaMediaEvent.EventState;
import com.android.car.media.testmediaapp.TmaMediaEvent.ResolutionIntent;
import com.android.car.media.testmediaapp.TmaMediaItem.TmaCustomAction;
import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaAccountType;
import com.android.car.media.testmediaapp.prefs.TmaPrefs;
import com.android.car.media.testmediaapp.prefs.TmaPrefsActivity;
/**
* This class simulates all media interactions (no sound is actually played).
*/
public class TmaPlayer extends MediaSessionCompat.Callback {
private static final String TAG = "TmaPlayer";
private final Context mContext;
private final TmaPrefs mPrefs;
private final TmaLibrary mLibrary;
private final AudioManager mAudioManager;
private final Handler mHandler;
private final Runnable mTrackTimer = this::onStop;
private final Runnable mEventTrigger = this::onProcessMediaEvent;
private final MediaSessionCompat mSession;
private final AudioFocusRequest mAudioFocusRequest;
/** Only updated when the state changes. */
private long mCurrentPositionMs = 0;
private float mPlaybackSpeed = 1.0f; // TODO: make variable.
private long mPlaybackStartTimeMs;
private boolean mIsPlaying;
@Nullable
private TmaMediaItem mActiveItem;
private int mNextEventIndex = -1;
TmaPlayer(Context context, TmaLibrary library, AudioManager audioManager, Handler handler,
MediaSessionCompat session) {
mContext = context;
mPrefs = TmaPrefs.getInstance(mContext);
mLibrary = library;
mAudioManager = audioManager;
mHandler = handler;
mSession = session;
// TODO add focus listener ?
mAudioFocusRequest = new AudioFocusRequest.Builder(AUDIOFOCUS_GAIN).build();
}
/** Updates the state in the media session based on the given {@link TmaMediaEvent}. */
void setPlaybackState(TmaMediaEvent event) {
Log.i(TAG, "setPlaybackState " + event);
PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
.setState(event.mState.mValue, mCurrentPositionMs, mPlaybackSpeed)
.setErrorMessage(event.mErrorCode.mValue, event.mErrorMessage)
.setActions(addActions(ACTION_PAUSE));
if (ResolutionIntent.PREFS.equals(event.mResolutionIntent)) {
Intent prefsIntent = new Intent();
prefsIntent.setClass(mContext, TmaPrefsActivity.class);
prefsIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, prefsIntent, 0);
Bundle extras = new Bundle();
extras.putString(ERROR_RESOLUTION_ACTION_LABEL, event.mActionLabel);
extras.putParcelable(ERROR_RESOLUTION_ACTION_INTENT, pendingIntent);
state.setExtras(extras);
}
setActiveItemState(state);
mSession.setPlaybackState(state.build());
}
/** Sets custom action, queue id, etc. */
private void setActiveItemState(PlaybackStateCompat.Builder state) {
if (mActiveItem != null) {
for (TmaCustomAction action : mActiveItem.mCustomActions) {
String name = mContext.getResources().getString(action.mNameId);
state.addCustomAction(action.mId, name, action.mIcon);
}
state.setActiveQueueItemId(mActiveItem.getQueueId());
}
}
private void playItem(@Nullable TmaMediaItem item) {
if (item != null && item.getParent() != null) {
if (mIsPlaying) {
stopPlayback();
}
mActiveItem = item;
mSession.setQueue(item.getParent().buildQueue());
startPlayBack(true);
}
}
@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
super.onPlayFromMediaId(mediaId, extras);
playItem(mLibrary.getMediaItemById(mediaId));
}
@Override
public void onPrepareFromMediaId(String mediaId, Bundle extras) {
super.onPrepareFromMediaId(mediaId, extras);
TmaMediaItem item = mLibrary.getMediaItemById(mediaId);
prepareMediaItem(item);
}
@Override
public void onPrepare() {
super.onPrepare();
if (!mSession.isActive()) {
mSession.setActive(true);
}
// Prepare the first playable item (at root level) as the active item
if (mActiveItem == null) {
TmaMediaItem root = mLibrary.getRoot(mPrefs.mRootNodeType.getValue());
if (root != null) {
prepareMediaItem(root.getPlayableByIndex(0));
}
}
}
void prepareMediaItem(@Nullable TmaMediaItem item) {
if (item != null && item.getParent() != null) {
if (mIsPlaying) {
stopPlayback();
}
mActiveItem = item;
mActiveItem.updateSessionMetadata(mSession);
mSession.setQueue(item.getParent().buildQueue());
PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_PAUSED, mCurrentPositionMs, mPlaybackSpeed)
.setActions(addActions(ACTION_PLAY));
setActiveItemState(state);
mSession.setPlaybackState(state.build());
}
}
@Override
public void onSkipToQueueItem(long id) {
super.onSkipToQueueItem(id);
if (mActiveItem != null && mActiveItem.getParent() != null) {
playItem(mActiveItem.getParent().getPlayableByIndex(id));
}
}
@Override
public void onSkipToNext() {
super.onSkipToNext();
if (mActiveItem != null) {
playItem(mActiveItem.getNext());
}
}
@Override
public void onSkipToPrevious() {
super.onSkipToPrevious();
if (mActiveItem != null) {
playItem(mActiveItem.getPrevious());
}
}
@Override
public void onPlay() {
super.onPlay();
startPlayBack(true);
}
@Override
public void onSeekTo(long pos) {
super.onSeekTo(pos);
boolean wasPlaying = mIsPlaying;
if (wasPlaying) {
mHandler.removeCallbacks(mTrackTimer);
}
mCurrentPositionMs = pos;
boolean requestAudioFocus = !wasPlaying;
startPlayBack(requestAudioFocus);
}
@Override
public void onPause() {
super.onPause();
pausePlayback();
}
@Override
public void onStop() {
super.onStop();
stopPlayback();
sendStopPlaybackState();
}
@Override
public void onCustomAction(String action, Bundle extras) {
super.onCustomAction(action, extras);
if (mActiveItem != null) {
if (TmaCustomAction.HEART_PLUS_PLUS.mId.equals(action)) {
mActiveItem.mHearts++;
toast("" + mActiveItem.mHearts);
} else if (TmaCustomAction.HEART_LESS_LESS.mId.equals(action)) {
mActiveItem.mHearts--;
toast("" + mActiveItem.mHearts);
}
}
}
/** Note: this is for quick feedback implementation, media apps should avoid toasts... */
private void toast(String message) {
Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
}
private boolean audioFocusGranted() {
return mAudioManager.requestAudioFocus(mAudioFocusRequest) == AUDIOFOCUS_REQUEST_GRANTED;
}
private void onProcessMediaEvent() {
if (mActiveItem == null) return;
TmaMediaEvent event = mActiveItem.mMediaEvents.get(mNextEventIndex);
event.maybeThrow();
if (event.premiumAccountRequired() &&
TmaAccountType.PAID.equals(mPrefs.mAccountType.getValue())) {
Log.i(TAG, "Ignoring even for paid account");
return;
} else if (Action.RESET_METADATA.equals(event.mAction)) {
mSession.setMetadata(mSession.getController().getMetadata());
} else {
setPlaybackState(event);
}
if (event.mState == EventState.PLAYING) {
if (!mSession.isActive()) {
mSession.setActive(true);
}
long trackDurationMs = mActiveItem.getDuration();
if (trackDurationMs > 0) {
mPlaybackStartTimeMs = System.currentTimeMillis();
long remainingMs = (long) ((trackDurationMs - mCurrentPositionMs) / mPlaybackSpeed);
mHandler.postDelayed(mTrackTimer, remainingMs);
}
mIsPlaying = true;
} else if (mIsPlaying) {
stopPlayback();
}
mNextEventIndex++;
if (mNextEventIndex < mActiveItem.mMediaEvents.size()) {
mHandler.postDelayed(mEventTrigger,
mActiveItem.mMediaEvents.get(mNextEventIndex).mPostDelayMs);
}
}
private void startPlayBack(boolean requestAudioFocus) {
if (requestAudioFocus && !audioFocusGranted()) return;
if (mActiveItem == null || mActiveItem.mMediaEvents.size() <= 0) {
PlaybackStateCompat state = new PlaybackStateCompat.Builder()
.setState(STATE_ERROR, mCurrentPositionMs, mPlaybackSpeed)
.setErrorMessage(ERROR_CODE_APP_ERROR, "null mActiveItem or empty events")
.build();
mSession.setPlaybackState(state);
return;
}
mActiveItem.updateSessionMetadata(mSession);
mHandler.removeCallbacks(mEventTrigger);
mNextEventIndex = 0;
mHandler.postDelayed(mEventTrigger, mActiveItem.mMediaEvents.get(0).mPostDelayMs);
}
private void pausePlayback() {
mCurrentPositionMs += (System.currentTimeMillis() - mPlaybackStartTimeMs) / mPlaybackSpeed;
mHandler.removeCallbacks(mTrackTimer);
PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_PAUSED, mCurrentPositionMs, mPlaybackSpeed)
.setActions(addActions(ACTION_PLAY));
setActiveItemState(state);
mSession.setPlaybackState(state.build());
mIsPlaying = false;
}
/** Doesn't change the playback state. */
private void stopPlayback() {
mCurrentPositionMs = 0;
mHandler.removeCallbacks(mTrackTimer);
mIsPlaying = false;
}
private void sendStopPlaybackState() {
PlaybackStateCompat.Builder state = new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_STOPPED, mCurrentPositionMs, mPlaybackSpeed)
.setActions(addActions(ACTION_PLAY));
setActiveItemState(state);
mSession.setPlaybackState(state.build());
}
private long addActions(long actions) {
actions |= ACTION_PLAY_FROM_MEDIA_ID | ACTION_SKIP_TO_QUEUE_ITEM | ACTION_SEEK_TO;
if (mActiveItem != null) {
if (mActiveItem.getNext() != null) {
actions |= ACTION_SKIP_TO_NEXT;
}
if (mActiveItem.getPrevious() != null) {
actions |= ACTION_SKIP_TO_PREVIOUS;
}
}
return actions;
}
}