package de.danoeh.antennapod.core.service.playback; import android.content.Context; import android.media.MediaPlayer; import android.support.annotation.NonNull; import android.util.Log; import android.util.Pair; import android.view.SurfaceHolder; import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.CastStatusCodes; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaStatus; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; import java.util.concurrent.atomic.AtomicBoolean; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.cast.CastConsumer; import de.danoeh.antennapod.core.cast.CastManager; import de.danoeh.antennapod.core.cast.CastUtils; import de.danoeh.antennapod.core.cast.DefaultCastConsumer; import de.danoeh.antennapod.core.cast.RemoteMedia; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; import de.danoeh.antennapod.core.util.playback.Playable; /** * Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices. */ public class RemotePSMP extends PlaybackServiceMediaPlayer { public static final String TAG = "RemotePSMP"; public static final int CAST_ERROR = 3001; public static final int CAST_ERROR_PRIORITY_HIGH = 3005; private final CastManager castMgr; private volatile Playable media; private volatile MediaInfo remoteMedia; private volatile MediaType mediaType; private final AtomicBoolean isBuffering; private final AtomicBoolean startWhenPrepared; public RemotePSMP(@NonNull Context context, @NonNull PSMPCallback callback) { super(context, callback); castMgr = CastManager.getInstance(); media = null; mediaType = null; startWhenPrepared = new AtomicBoolean(false); isBuffering = new AtomicBoolean(false); try { if (castMgr.isConnected() && castMgr.isRemoteMediaLoaded()) { // updates the state, but does not start playing new media if it was going to onRemoteMediaPlayerStatusUpdated( ((p, playNextEpisode, wasSkipped, switchingPlayers) -> this.callback.endPlayback(p, false, wasSkipped, switchingPlayers))); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to do initial check for loaded media", e); } castMgr.addCastConsumer(castConsumer); //TODO } private CastConsumer castConsumer = new DefaultCastConsumer() { @Override public void onRemoteMediaPlayerMetadataUpdated() { RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); } @Override public void onRemoteMediaPlayerStatusUpdated() { RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); } @Override public void onMediaLoadResult(int statusCode) { if (playerStatus == PlayerStatus.PREPARING) { if (statusCode == CastStatusCodes.SUCCESS) { setPlayerStatus(PlayerStatus.PREPARED, media); if (media.getDuration() == 0) { Log.d(TAG, "Setting duration of media"); try { media.setDuration((int) castMgr.getMediaDuration()); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to get remote media's duration"); } } } else if (statusCode != CastStatusCodes.REPLACED){ Log.d(TAG, "Remote media failed to load"); setPlayerStatus(PlayerStatus.INITIALIZED, media); } } else { Log.d(TAG, "onMediaLoadResult called, but Player Status wasn't in preparing state, so we ignore the result"); } } @Override public void onApplicationStatusChanged(String appStatus) { if (playerStatus != PlayerStatus.PLAYING) { Log.d(TAG, "onApplicationStatusChanged, but no media was playing"); return; } boolean playbackEnded = false; try { int standbyState = castMgr.getApplicationStandbyState(); Log.d(TAG, "standbyState: " + standbyState); playbackEnded = standbyState == Cast.STANDBY_STATE_YES; } catch (IllegalStateException e) { Log.d(TAG, "unable to get standbyState on onApplicationStatusChanged()"); } if (playbackEnded) { setPlayerStatus(PlayerStatus.INDETERMINATE, media); callback.endPlayback(media, true, false, false); } } @Override public void onFailed(int resourceId, int statusCode) { callback.onMediaPlayerInfo(CAST_ERROR, resourceId); } }; private void setBuffering(boolean buffering) { if (buffering && isBuffering.compareAndSet(false, true)) { callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); } else if (!buffering && isBuffering.compareAndSet(true, false)) { callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); } } private Playable localVersion(MediaInfo info){ if (info == null) { return null; } if (CastUtils.matches(info, media)) { return media; } return CastUtils.getPlayable(info, true); } private MediaInfo remoteVersion(Playable playable) { if (playable == null) { return null; } if (CastUtils.matches(remoteMedia, playable)) { return remoteMedia; } if (playable instanceof FeedMedia) { return CastUtils.convertFromFeedMedia((FeedMedia) playable); } if (playable instanceof RemoteMedia) { return ((RemoteMedia) playable).extractMediaInfo(); } return null; } private void onRemoteMediaPlayerStatusUpdated(@NonNull EndPlaybackCall endPlaybackCall) { MediaStatus status = castMgr.getMediaStatus(); if (status == null) { Log.d(TAG, "Received null MediaStatus"); //setBuffering(false); //setPlayerStatus(PlayerStatus.INDETERMINATE, null); return; } else { Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState()); } Playable currentMedia = localVersion(status.getMediaInfo()); boolean updateUI = currentMedia != media; if (currentMedia != null) { long position = status.getStreamPosition(); if (position > 0 && currentMedia.getPosition() == 0) { currentMedia.setPosition((int) position); } } int state = status.getPlayerState(); setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING); switch (state) { case MediaStatus.PLAYER_STATE_PLAYING: setPlayerStatus(PlayerStatus.PLAYING, currentMedia); break; case MediaStatus.PLAYER_STATE_PAUSED: setPlayerStatus(PlayerStatus.PAUSED, currentMedia); break; case MediaStatus.PLAYER_STATE_BUFFERING: setPlayerStatus(playerStatus, currentMedia); break; case MediaStatus.PLAYER_STATE_IDLE: int reason = status.getIdleReason(); switch (reason) { case MediaStatus.IDLE_REASON_CANCELED: // check if we're already loading something else if (!updateUI || media == null) { setPlayerStatus(PlayerStatus.STOPPED, currentMedia); } else { updateUI = false; } break; case MediaStatus.IDLE_REASON_INTERRUPTED: // check if we're already loading something else if (!updateUI || media == null) { setPlayerStatus(PlayerStatus.PREPARING, currentMedia); } else { updateUI = false; } break; case MediaStatus.IDLE_REASON_NONE: setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia); break; case MediaStatus.IDLE_REASON_FINISHED: boolean playing = playerStatus == PlayerStatus.PLAYING; setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); endPlaybackCall.endPlayback(currentMedia,playing, false, false); // endPlayback already updates the UI, so no need to trigger it ourselves updateUI = false; break; case MediaStatus.IDLE_REASON_ERROR: Log.w(TAG, "Got an error status from the Chromecast. Skipping, if possible, to the next episode..."); setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_failed_media_error_skipping); endPlaybackCall.endPlayback(currentMedia, startWhenPrepared.get(), true, false); // endPlayback already updates the UI, so no need to trigger it ourselves updateUI = false; } break; case MediaStatus.PLAYER_STATE_UNKNOWN: //is this right? setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); break; default: Log.e(TAG, "Remote media state undetermined!"); setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); } if (updateUI) { callback.onMediaChanged(true); } } @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(de.danoeh.antennapod.core.util.playback.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)) { Log.d(TAG, "media provided is not compatible with cast device"); callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_not_castable); try { playable.loadMetadata(); } catch (Playable.PlayableException e) { Log.e(TAG, "Unable to load metadata of playable", e); } callback.endPlayback(playable, startWhenPrepared, true, false); 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 try { if (castMgr.isRemoteMediaPlaying()) { setPlayerStatus(PlayerStatus.PAUSED, media); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to determine whether media was playing, falling back to stored player status", e); // this might end up just being pointless if we need to query the remote device for the position if (playerStatus == PlayerStatus.PLAYING) { setPlayerStatus(PlayerStatus.PAUSED, media); } } smartMarkAsPlayed(media); setPlayerStatus(PlayerStatus.INDETERMINATE, null); } } this.media = playable; remoteMedia = remoteVersion(playable); //this.stream = stream; this.mediaType = media.getMediaType(); this.startWhenPrepared.set(startWhenPrepared); setPlayerStatus(PlayerStatus.INITIALIZING, media); try { media.loadMetadata(); callback.onMediaChanged(true); setPlayerStatus(PlayerStatus.INITIALIZED, media); if (prepareImmediately) { prepare(); } } catch (Playable.PlayableException e) { Log.e(TAG, "Error while loading media metadata", e); setPlayerStatus(PlayerStatus.STOPPED, null); } } @Override public void resume() { try { // TODO see comment on prepare() // setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( media.getPosition(), media.getLastPlayedTime()); castMgr.play(newPosition); } castMgr.play(); } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to resume remote playback", e); } } @Override public void pause(boolean abandonFocus, boolean reinit) { try { if (castMgr.isRemoteMediaPlaying()) { castMgr.pause(); } } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to pause", e); } } @Override public void prepare() { if (playerStatus == PlayerStatus.INITIALIZED) { Log.d(TAG, "Preparing media player"); setPlayerStatus(PlayerStatus.PREPARING, media); try { int position = media.getPosition(); if (position > 0) { position = RewindAfterPauseUtils.calculatePositionWithRewind( position, media.getLastPlayedTime()); } // TODO We're not supporting user set stream volume yet, as we need to make a UI // that doesn't allow changing playback speed or have different values for left/right //setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); castMgr.loadMedia(remoteMedia, startWhenPrepared.get(), position); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Error loading media", e); setPlayerStatus(PlayerStatus.INITIALIZED, media); } } } @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) { //TODO check other seek implementations and see if there's no issue with sending too many seek commands to the remote media player try { if (castMgr.isRemoteMediaLoaded()) { setPlayerStatus(PlayerStatus.SEEKING, media); castMgr.seek(t); } else if (media != null && playerStatus == PlayerStatus.INITIALIZED){ media.setPosition(t); startWhenPrepared.set(false); prepare(); } } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to seek", e); } } @Override public void seekDelta(int d) { int position = getPosition(); if (position != INVALID_TIME) { seekTo(position + d); } else { Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); } } @Override public int getDuration() { int retVal = INVALID_TIME; boolean prepared; try { prepared = castMgr.isRemoteMediaLoaded(); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to check if remote media is loaded", e); prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); } if (prepared) { try { retVal = (int) castMgr.getMediaDuration(); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to determine remote media's duration", e); } } if(retVal == INVALID_TIME && media != null && media.getDuration() > 0) { retVal = media.getDuration(); } Log.d(TAG, "getDuration() -> " + retVal); return retVal; } @Override public int getPosition() { int retVal = INVALID_TIME; boolean prepared; try { prepared = castMgr.isRemoteMediaLoaded(); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to check if remote media is loaded", e); prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); } if (prepared) { try { retVal = (int) castMgr.getCurrentMediaPosition(); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Unable to determine remote media's position", e); } } if(retVal <= 0 && media != null && media.getPosition() >= 0) { retVal = media.getPosition(); } Log.d(TAG, "getPosition() -> " + retVal); return retVal; } @Override public boolean isStartWhenPrepared() { return startWhenPrepared.get(); } @Override public void setStartWhenPrepared(boolean startWhenPrepared) { this.startWhenPrepared.set(startWhenPrepared); } //TODO I believe some parts of the code make the same decision skipping this check, so that //should be changed as well @Override public boolean canSetSpeed() { return false; } @Override public void setSpeed(float speed) { throw new UnsupportedOperationException("Setting playback speed unsupported for Remote Playback"); } @Override public float getPlaybackSpeed() { return 1; } @Override public void setVolume(float volumeLeft, float volumeRight) { Log.d(TAG, "Setting the Stream volume on Remote Media Player"); double volume = (volumeLeft+volumeRight)/2; if (volume > 1.0) { volume = 1.0; } if (volume < 0.0) { volume = 0.0; } try { castMgr.setStreamVolume(volume); } catch (TransientNetworkDisconnectionException | NoConnectionException | CastException e) { Log.e(TAG, "Unable to set the volume", e); } } @Override public boolean canDownmix() { return false; } @Override public void setDownmix(boolean enable) { throw new UnsupportedOperationException("Setting downmix unsupported in Remote Media Player"); } @Override public MediaType getCurrentMediaType() { return mediaType; } @Override public boolean isStreaming() { return true; } @Override public void shutdown() { castMgr.removeCastConsumer(castConsumer); } @Override public void shutdownQuietly() { shutdown(); } @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 void endPlayback(boolean wasSkipped, boolean switchingPlayers) { Log.d(TAG, "endPlayback() called"); boolean isPlaying = playerStatus == PlayerStatus.PLAYING; try { isPlaying = castMgr.isRemoteMediaPlaying(); } catch (TransientNetworkDisconnectionException | NoConnectionException e) { Log.e(TAG, "Could not determine if media is playing", e); } // TODO make sure we stop playback whenever there's no next episode. if (playerStatus != PlayerStatus.INDETERMINATE) { setPlayerStatus(PlayerStatus.INDETERMINATE, media); } callback.endPlayback(media, isPlaying, wasSkipped, switchingPlayers); } @Override public void stop() { if (playerStatus == PlayerStatus.INDETERMINATE) { setPlayerStatus(PlayerStatus.STOPPED, null); } else { Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); } } @Override protected boolean shouldLockWifi() { return false; } private interface EndPlaybackCall { boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers); } }