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 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 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; } }