549 lines
21 KiB
Java
549 lines
21 KiB
Java
package de.danoeh.antennapod.playback.cast;
|
|
|
|
import android.annotation.SuppressLint;
|
|
import android.content.Context;
|
|
import androidx.annotation.NonNull;
|
|
import android.util.Log;
|
|
import android.util.Pair;
|
|
import android.view.SurfaceHolder;
|
|
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
|
import androidx.annotation.Nullable;
|
|
import com.google.android.gms.cast.MediaError;
|
|
import com.google.android.gms.cast.MediaInfo;
|
|
import com.google.android.gms.cast.MediaLoadOptions;
|
|
import com.google.android.gms.cast.MediaLoadRequestData;
|
|
import com.google.android.gms.cast.MediaSeekOptions;
|
|
import com.google.android.gms.cast.MediaStatus;
|
|
import com.google.android.gms.cast.framework.CastContext;
|
|
import com.google.android.gms.cast.framework.CastState;
|
|
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
|
|
import com.google.android.gms.common.ConnectionResult;
|
|
import com.google.android.gms.common.GoogleApiAvailability;
|
|
import de.danoeh.antennapod.event.PlayerErrorEvent;
|
|
import de.danoeh.antennapod.event.playback.BufferUpdateEvent;
|
|
import de.danoeh.antennapod.model.feed.FeedMedia;
|
|
import de.danoeh.antennapod.model.playback.MediaType;
|
|
import de.danoeh.antennapod.model.playback.Playable;
|
|
import de.danoeh.antennapod.model.playback.RemoteMedia;
|
|
import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
|
|
import de.danoeh.antennapod.playback.base.PlayerStatus;
|
|
import de.danoeh.antennapod.playback.base.RewindAfterPauseUtils;
|
|
import org.greenrobot.eventbus.EventBus;
|
|
|
|
/**
|
|
* Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices.
|
|
*/
|
|
@SuppressLint("VisibleForTests")
|
|
public class CastPsmp extends PlaybackServiceMediaPlayer {
|
|
|
|
public static final String TAG = "CastPSMP";
|
|
|
|
private volatile Playable media;
|
|
private volatile MediaType mediaType;
|
|
private volatile MediaInfo remoteMedia;
|
|
private volatile int remoteState;
|
|
private final CastContext castContext;
|
|
private final RemoteMediaClient remoteMediaClient;
|
|
|
|
private final AtomicBoolean isBuffering;
|
|
|
|
private final AtomicBoolean startWhenPrepared;
|
|
|
|
@Nullable
|
|
public static PlaybackServiceMediaPlayer getInstanceIfConnected(@NonNull Context context,
|
|
@NonNull PSMPCallback callback) {
|
|
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) {
|
|
return null;
|
|
}
|
|
if (CastContext.getSharedInstance(context).getCastState() == CastState.CONNECTED) {
|
|
return new CastPsmp(context, callback);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public CastPsmp(@NonNull Context context, @NonNull PSMPCallback callback) {
|
|
super(context, callback);
|
|
|
|
castContext = CastContext.getSharedInstance(context);
|
|
remoteMediaClient = castContext.getSessionManager().getCurrentCastSession().getRemoteMediaClient();
|
|
remoteMediaClient.registerCallback(remoteMediaClientCallback);
|
|
media = null;
|
|
mediaType = null;
|
|
startWhenPrepared = new AtomicBoolean(false);
|
|
isBuffering = new AtomicBoolean(false);
|
|
remoteState = MediaStatus.PLAYER_STATE_UNKNOWN;
|
|
}
|
|
|
|
private final RemoteMediaClient.Callback remoteMediaClientCallback = new RemoteMediaClient.Callback() {
|
|
@Override
|
|
public void onMetadataUpdated() {
|
|
super.onMetadataUpdated();
|
|
onRemoteMediaPlayerStatusUpdated();
|
|
}
|
|
|
|
@Override
|
|
public void onPreloadStatusUpdated() {
|
|
super.onPreloadStatusUpdated();
|
|
onRemoteMediaPlayerStatusUpdated();
|
|
}
|
|
|
|
@Override
|
|
public void onStatusUpdated() {
|
|
super.onStatusUpdated();
|
|
onRemoteMediaPlayerStatusUpdated();
|
|
}
|
|
|
|
@Override
|
|
public void onMediaError(@NonNull MediaError mediaError) {
|
|
EventBus.getDefault().post(new PlayerErrorEvent(mediaError.getReason()));
|
|
}
|
|
};
|
|
|
|
private void setBuffering(boolean buffering) {
|
|
if (buffering && isBuffering.compareAndSet(false, true)) {
|
|
EventBus.getDefault().post(BufferUpdateEvent.started());
|
|
} else if (!buffering && isBuffering.compareAndSet(true, false)) {
|
|
EventBus.getDefault().post(BufferUpdateEvent.ended());
|
|
}
|
|
}
|
|
|
|
private Playable localVersion(MediaInfo info) {
|
|
if (info == null || info.getMetadata() == null) {
|
|
return null;
|
|
}
|
|
if (CastUtils.matches(info, media)) {
|
|
return media;
|
|
}
|
|
String streamUrl = info.getMetadata().getString(CastUtils.KEY_STREAM_URL);
|
|
return streamUrl == null ? CastUtils.makeRemoteMedia(info) : callback.findMedia(streamUrl);
|
|
}
|
|
|
|
private MediaInfo remoteVersion(Playable playable) {
|
|
if (playable == null) {
|
|
return null;
|
|
}
|
|
if (CastUtils.matches(remoteMedia, playable)) {
|
|
return remoteMedia;
|
|
}
|
|
if (playable instanceof FeedMedia) {
|
|
return MediaInfoCreator.from((FeedMedia) playable);
|
|
}
|
|
if (playable instanceof RemoteMedia) {
|
|
return MediaInfoCreator.from((RemoteMedia) playable);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void onRemoteMediaPlayerStatusUpdated() {
|
|
MediaStatus status = remoteMediaClient.getMediaStatus();
|
|
if (status == null) {
|
|
Log.d(TAG, "Received null MediaStatus");
|
|
return;
|
|
} else {
|
|
Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState());
|
|
}
|
|
int state = status.getPlayerState();
|
|
int oldState = remoteState;
|
|
remoteMedia = status.getMediaInfo();
|
|
boolean mediaChanged = !CastUtils.matches(remoteMedia, media);
|
|
boolean stateChanged = state != oldState;
|
|
if (!mediaChanged && !stateChanged) {
|
|
Log.d(TAG, "Both media and state haven't changed, so nothing to do");
|
|
return;
|
|
}
|
|
Playable currentMedia = mediaChanged ? localVersion(remoteMedia) : media;
|
|
Playable oldMedia = media;
|
|
int position = (int) status.getStreamPosition();
|
|
// check for incompatible states
|
|
if ((state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_PAUSED)
|
|
&& currentMedia == null) {
|
|
Log.w(TAG, "RemoteMediaPlayer returned playing or pausing state, but with no media");
|
|
state = MediaStatus.PLAYER_STATE_UNKNOWN;
|
|
stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN;
|
|
}
|
|
|
|
if (stateChanged) {
|
|
remoteState = state;
|
|
}
|
|
|
|
if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING
|
|
&& state != MediaStatus.PLAYER_STATE_IDLE) {
|
|
callback.onPlaybackPause(null, Playable.INVALID_TIME);
|
|
// We don't want setPlayerStatus to handle the onPlaybackPause callback
|
|
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
|
|
}
|
|
|
|
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING);
|
|
|
|
switch (state) {
|
|
case MediaStatus.PLAYER_STATE_PLAYING:
|
|
if (!stateChanged) {
|
|
//These steps are necessary because they won't be performed by setPlayerStatus()
|
|
if (position >= 0) {
|
|
currentMedia.setPosition(position);
|
|
}
|
|
currentMedia.onPlaybackStart();
|
|
}
|
|
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position);
|
|
break;
|
|
case MediaStatus.PLAYER_STATE_PAUSED:
|
|
setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position);
|
|
break;
|
|
case MediaStatus.PLAYER_STATE_BUFFERING:
|
|
setPlayerStatus((mediaChanged || playerStatus == PlayerStatus.PREPARING)
|
|
? PlayerStatus.PREPARING : PlayerStatus.SEEKING, currentMedia,
|
|
currentMedia != null ? currentMedia.getPosition() : Playable.INVALID_TIME);
|
|
break;
|
|
case MediaStatus.PLAYER_STATE_IDLE:
|
|
int reason = status.getIdleReason();
|
|
switch (reason) {
|
|
case MediaStatus.IDLE_REASON_CANCELED:
|
|
// Essentially means stopped at the request of a user
|
|
callback.onPlaybackEnded(null, true);
|
|
setPlayerStatus(PlayerStatus.STOPPED, currentMedia);
|
|
if (oldMedia != null) {
|
|
if (position >= 0) {
|
|
oldMedia.setPosition(position);
|
|
}
|
|
callback.onPostPlayback(oldMedia, false, false, false);
|
|
}
|
|
// onPlaybackEnded pretty much takes care of updating the UI
|
|
return;
|
|
case MediaStatus.IDLE_REASON_INTERRUPTED:
|
|
// Means that a request to load a different media was sent
|
|
// Not sure if currentMedia already reflects the to be loaded one
|
|
if (mediaChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING) {
|
|
callback.onPlaybackPause(null, Playable.INVALID_TIME);
|
|
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
|
|
}
|
|
setPlayerStatus(PlayerStatus.PREPARING, currentMedia);
|
|
break;
|
|
case MediaStatus.IDLE_REASON_NONE:
|
|
// This probably only happens when we connected but no command has been sent yet.
|
|
setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia);
|
|
break;
|
|
case MediaStatus.IDLE_REASON_FINISHED:
|
|
// This is our onCompletionListener...
|
|
if (mediaChanged && currentMedia != null) {
|
|
media = currentMedia;
|
|
}
|
|
endPlayback(true, false, true, true);
|
|
return;
|
|
case MediaStatus.IDLE_REASON_ERROR:
|
|
Log.w(TAG, "Got an error status from the Chromecast. "
|
|
+ "Skipping, if possible, to the next episode...");
|
|
EventBus.getDefault().post(new PlayerErrorEvent("Chromecast error code 1"));
|
|
endPlayback(false, false, true, true);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
break;
|
|
case MediaStatus.PLAYER_STATE_UNKNOWN:
|
|
if (playerStatus != PlayerStatus.INDETERMINATE || media != currentMedia) {
|
|
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
|
|
}
|
|
break;
|
|
default:
|
|
Log.w(TAG, "Remote media state undetermined!");
|
|
}
|
|
if (mediaChanged) {
|
|
callback.onMediaChanged(true);
|
|
if (oldMedia != null) {
|
|
callback.onPostPlayback(oldMedia, false, false, currentMedia != null);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void playMediaObject(@NonNull final Playable playable, final boolean stream,
|
|
final boolean startWhenPrepared, final boolean prepareImmediately) {
|
|
Log.d(TAG, "playMediaObject() called");
|
|
playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately);
|
|
}
|
|
|
|
/**
|
|
* Internal implementation of playMediaObject. This method has an additional parameter that
|
|
* allows the caller to force a media player reset even if
|
|
* the given playable parameter is the same object as the currently playing media.
|
|
*
|
|
* @see #playMediaObject(Playable, boolean, boolean, boolean)
|
|
*/
|
|
private void playMediaObject(@NonNull final Playable playable, final boolean forceReset,
|
|
final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) {
|
|
if (!CastUtils.isCastable(playable, castContext.getSessionManager().getCurrentCastSession())) {
|
|
Log.d(TAG, "media provided is not compatible with cast device");
|
|
EventBus.getDefault().post(new PlayerErrorEvent("Media not compatible with cast device"));
|
|
Playable nextPlayable = playable;
|
|
do {
|
|
nextPlayable = callback.getNextInQueue(nextPlayable);
|
|
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable,
|
|
castContext.getSessionManager().getCurrentCastSession()));
|
|
if (nextPlayable != null) {
|
|
playMediaObject(nextPlayable, forceReset, stream, startWhenPrepared, prepareImmediately);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (media != null) {
|
|
if (!forceReset && media.getIdentifier().equals(playable.getIdentifier())
|
|
&& playerStatus == PlayerStatus.PLAYING) {
|
|
// episode is already playing -> ignore method call
|
|
Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing.");
|
|
return;
|
|
} else {
|
|
// set temporarily to pause in order to update list with current position
|
|
boolean isPlaying = remoteMediaClient.isPlaying();
|
|
int position = (int) remoteMediaClient.getApproximateStreamPosition();
|
|
if (isPlaying) {
|
|
callback.onPlaybackPause(media, position);
|
|
}
|
|
if (!media.getIdentifier().equals(playable.getIdentifier())) {
|
|
final Playable oldMedia = media;
|
|
callback.onPostPlayback(oldMedia, false, false, true);
|
|
}
|
|
setPlayerStatus(PlayerStatus.INDETERMINATE, null);
|
|
}
|
|
}
|
|
|
|
this.media = playable;
|
|
remoteMedia = remoteVersion(playable);
|
|
this.mediaType = media.getMediaType();
|
|
this.startWhenPrepared.set(startWhenPrepared);
|
|
setPlayerStatus(PlayerStatus.INITIALIZING, media);
|
|
callback.ensureMediaInfoLoaded(media);
|
|
callback.onMediaChanged(true);
|
|
setPlayerStatus(PlayerStatus.INITIALIZED, media);
|
|
if (prepareImmediately) {
|
|
prepare();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void resume() {
|
|
int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind(
|
|
media.getPosition(),
|
|
media.getLastPlayedTime());
|
|
seekTo(newPosition);
|
|
remoteMediaClient.play();
|
|
}
|
|
|
|
@Override
|
|
public void pause(boolean abandonFocus, boolean reinit) {
|
|
remoteMediaClient.pause();
|
|
}
|
|
|
|
@Override
|
|
public void prepare() {
|
|
if (playerStatus == PlayerStatus.INITIALIZED) {
|
|
Log.d(TAG, "Preparing media player");
|
|
setPlayerStatus(PlayerStatus.PREPARING, media);
|
|
int position = media.getPosition();
|
|
if (position > 0) {
|
|
position = RewindAfterPauseUtils.calculatePositionWithRewind(
|
|
position,
|
|
media.getLastPlayedTime());
|
|
}
|
|
remoteMediaClient.load(new MediaLoadRequestData.Builder()
|
|
.setMediaInfo(remoteMedia)
|
|
.setAutoplay(startWhenPrepared.get())
|
|
.setCurrentTime(position).build());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void reinit() {
|
|
Log.d(TAG, "reinit() called");
|
|
if (media != null) {
|
|
playMediaObject(media, true, false, startWhenPrepared.get(), false);
|
|
} else {
|
|
Log.d(TAG, "Call to reinit was ignored: media was null");
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void seekTo(int t) {
|
|
new Exception("Seeking to " + t).printStackTrace();
|
|
remoteMediaClient.seek(new MediaSeekOptions.Builder()
|
|
.setPosition(t).build());
|
|
}
|
|
|
|
@Override
|
|
public void seekDelta(int d) {
|
|
int position = getPosition();
|
|
if (position != Playable.INVALID_TIME) {
|
|
seekTo(position + d);
|
|
} else {
|
|
Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta");
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public int getDuration() {
|
|
int retVal = (int) remoteMediaClient.getStreamDuration();
|
|
if (retVal == Playable.INVALID_TIME && media != null && media.getDuration() > 0) {
|
|
retVal = media.getDuration();
|
|
}
|
|
return retVal;
|
|
}
|
|
|
|
@Override
|
|
public int getPosition() {
|
|
int retVal = (int) remoteMediaClient.getApproximateStreamPosition();
|
|
if (retVal <= 0 && media != null && media.getPosition() >= 0) {
|
|
retVal = media.getPosition();
|
|
}
|
|
return retVal;
|
|
}
|
|
|
|
@Override
|
|
public boolean isStartWhenPrepared() {
|
|
return startWhenPrepared.get();
|
|
}
|
|
|
|
@Override
|
|
public void setStartWhenPrepared(boolean startWhenPrepared) {
|
|
this.startWhenPrepared.set(startWhenPrepared);
|
|
}
|
|
|
|
@Override
|
|
public void setPlaybackParams(float speed, boolean skipSilence) {
|
|
double playbackRate = (float) Math.max(MediaLoadOptions.PLAYBACK_RATE_MIN,
|
|
Math.min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed));
|
|
remoteMediaClient.setPlaybackRate(playbackRate);
|
|
}
|
|
|
|
@Override
|
|
public float getPlaybackSpeed() {
|
|
MediaStatus status = remoteMediaClient.getMediaStatus();
|
|
return status != null ? (float) status.getPlaybackRate() : 1.0f;
|
|
}
|
|
|
|
@Override
|
|
public void setVolume(float volumeLeft, float volumeRight) {
|
|
Log.d(TAG, "Setting the Stream volume on Remote Media Player");
|
|
remoteMediaClient.setStreamVolume(volumeLeft);
|
|
}
|
|
|
|
@Override
|
|
public MediaType getCurrentMediaType() {
|
|
return mediaType;
|
|
}
|
|
|
|
@Override
|
|
public boolean isStreaming() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public void shutdown() {
|
|
remoteMediaClient.unregisterCallback(remoteMediaClientCallback);
|
|
}
|
|
|
|
@Override
|
|
public void setVideoSurface(SurfaceHolder surface) {
|
|
throw new UnsupportedOperationException("Setting Video Surface unsupported in Remote Media Player");
|
|
}
|
|
|
|
@Override
|
|
public void resetVideoSurface() {
|
|
Log.e(TAG, "Resetting Video Surface unsupported in Remote Media Player");
|
|
}
|
|
|
|
@Override
|
|
public Pair<Integer, Integer> getVideoSize() {
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Playable getPlayable() {
|
|
return media;
|
|
}
|
|
|
|
@Override
|
|
protected void setPlayable(Playable playable) {
|
|
if (playable != media) {
|
|
media = playable;
|
|
remoteMedia = remoteVersion(playable);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public List<String> getAudioTracks() {
|
|
return Collections.emptyList();
|
|
}
|
|
|
|
public void setAudioTrack(int track) {
|
|
|
|
}
|
|
|
|
public int getSelectedAudioTrack() {
|
|
return -1;
|
|
}
|
|
|
|
@Override
|
|
protected void endPlayback(boolean hasEnded, boolean wasSkipped, boolean shouldContinue,
|
|
boolean toStoppedState) {
|
|
Log.d(TAG, "endPlayback() called");
|
|
boolean isPlaying = playerStatus == PlayerStatus.PLAYING;
|
|
if (playerStatus != PlayerStatus.INDETERMINATE) {
|
|
setPlayerStatus(PlayerStatus.INDETERMINATE, media);
|
|
}
|
|
if (media != null && wasSkipped) {
|
|
// current position only really matters when we skip
|
|
int position = getPosition();
|
|
if (position >= 0) {
|
|
media.setPosition(position);
|
|
}
|
|
}
|
|
final Playable currentMedia = media;
|
|
Playable nextMedia = null;
|
|
if (shouldContinue) {
|
|
nextMedia = callback.getNextInQueue(currentMedia);
|
|
|
|
boolean playNextEpisode = isPlaying && nextMedia != null;
|
|
if (playNextEpisode) {
|
|
Log.d(TAG, "Playback of next episode will start immediately.");
|
|
} else if (nextMedia == null) {
|
|
Log.d(TAG, "No more episodes available to play");
|
|
} else {
|
|
Log.d(TAG, "Loading next episode, but not playing automatically.");
|
|
}
|
|
|
|
if (nextMedia != null) {
|
|
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode);
|
|
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing
|
|
media = null;
|
|
playMediaObject(nextMedia, false, true, playNextEpisode, playNextEpisode);
|
|
}
|
|
}
|
|
if (shouldContinue || toStoppedState) {
|
|
if (nextMedia == null) {
|
|
remoteMediaClient.stop();
|
|
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
|
|
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, false);
|
|
} else {
|
|
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, true);
|
|
}
|
|
} else if (isPlaying) {
|
|
callback.onPlaybackPause(currentMedia,
|
|
currentMedia != null ? currentMedia.getPosition() : Playable.INVALID_TIME);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected boolean shouldLockWifi() {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean isCasting() {
|
|
return true;
|
|
}
|
|
}
|