diff options
author | ByteHamster <ByteHamster@users.noreply.github.com> | 2021-11-28 22:19:14 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-11-28 22:19:14 +0100 |
commit | f0100e61ac633516082ea112363132c99f7c0b7a (patch) | |
tree | f7598c0cee85780b409ab895a8041d1607eec312 /playback/cast/src/play/java/de | |
parent | af2835c59dcb0473aba7a48b38f5abe28dca34d3 (diff) | |
download | AntennaPod-f0100e61ac633516082ea112363132c99f7c0b7a.zip |
Chromecast rework (#5518)
Diffstat (limited to 'playback/cast/src/play/java/de')
6 files changed, 1013 insertions, 0 deletions
diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java new file mode 100644 index 000000000..2cebde6a3 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.playback.cast; + +import android.os.Bundle; +import android.view.Menu; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +/** + * Activity that allows for showing the MediaRouter button whenever there's a cast device in the + * network. + */ +public abstract class CastEnabledActivity extends AppCompatActivity { + private static final String TAG = "CastEnabledActivity"; + private boolean canCast = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS; + if (canCast) { + CastContext.getSharedInstance(this); + } + } + + public void requestCastButton(Menu menu) { + if (!canCast) { + return; + } + getMenuInflater().inflate(R.menu.cast_button, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, R.id.media_route_menu_item); + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java new file mode 100644 index 000000000..37885bdd0 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastOptions; +import com.google.android.gms.cast.framework.OptionsProvider; +import com.google.android.gms.cast.framework.SessionProvider; + +import java.util.List; + +@SuppressWarnings("unused") +public class CastOptionsProvider implements OptionsProvider { + @Override + @NonNull + public CastOptions getCastOptions(@NonNull Context context) { + return new CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .build(); + } + + @Override + public List<SessionProvider> getAdditionalSessionProviders(@NonNull Context context) { + return null; + } +}
\ No newline at end of file diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java new file mode 100644 index 000000000..8e74154e8 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java @@ -0,0 +1,567 @@ +package de.danoeh.antennapod.playback.cast; + +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.Future; +import java.util.concurrent.FutureTask; +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. + */ +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, 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() : 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, 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 != 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 == 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 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() { + remoteMediaClient.unregisterCallback(remoteMediaClientCallback); + } + + @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 List<String> getAudioTracks() { + return Collections.emptyList(); + } + + public void setAudioTrack(int track) { + + } + + public int getSelectedAudioTrack() { + return -1; + } + + @Override + protected Future<?> 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() : INVALID_TIME); + } + + FutureTask<?> future = new FutureTask<>(() -> { }, null); + future.run(); + return future; + } + + @Override + protected boolean shouldLockWifi() { + return false; + } + + @Override + public boolean isCasting() { + return true; + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java new file mode 100644 index 000000000..39f54b11c --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +public class CastStateListener implements SessionManagerListener<CastSession> { + private final CastContext castContext; + + public CastStateListener(Context context) { + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) { + castContext = null; + return; + } + castContext = CastContext.getSharedInstance(context); + castContext.getSessionManager().addSessionManagerListener(this, CastSession.class); + } + + public void destroy() { + if (castContext != null) { + castContext.getSessionManager().removeSessionManagerListener(this, CastSession.class); + } + } + + @Override + public void onSessionStarting(@NonNull CastSession castSession) { + } + + @Override + public void onSessionStarted(@NonNull CastSession session, @NonNull String sessionId) { + onSessionStartedOrEnded(); + } + + @Override + public void onSessionStartFailed(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionEnding(@NonNull CastSession castSession) { + } + + @Override + public void onSessionResumed(@NonNull CastSession session, boolean wasSuspended) { + } + + @Override + public void onSessionResumeFailed(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionSuspended(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionEnded(@NonNull CastSession session, int error) { + onSessionStartedOrEnded(); + } + + @Override + public void onSessionResuming(@NonNull CastSession castSession, @NonNull String s) { + } + + public void onSessionStartedOrEnded() { + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java new file mode 100644 index 000000000..312b6b2f9 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java @@ -0,0 +1,181 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.ContentResolver; +import android.util.Log; +import android.text.TextUtils; +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.common.images.WebImage; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.model.playback.RemoteMedia; + +import java.util.List; + +/** + * Helper functions for Cast support. + */ +public class CastUtils { + private CastUtils() { + } + + private static final String TAG = "CastUtils"; + + public static final String KEY_MEDIA_ID = "de.danoeh.antennapod.core.cast.MediaId"; + + public static final String KEY_EPISODE_IDENTIFIER = "de.danoeh.antennapod.core.cast.EpisodeId"; + public static final String KEY_EPISODE_LINK = "de.danoeh.antennapod.core.cast.EpisodeLink"; + public static final String KEY_STREAM_URL = "de.danoeh.antennapod.core.cast.StreamUrl"; + public static final String KEY_FEED_URL = "de.danoeh.antennapod.core.cast.FeedUrl"; + public static final String KEY_FEED_WEBSITE = "de.danoeh.antennapod.core.cast.FeedWebsite"; + public static final String KEY_EPISODE_NOTES = "de.danoeh.antennapod.core.cast.EpisodeNotes"; + + /** + * The field <code>AntennaPod.FormatVersion</code> specifies which version of MediaMetaData + * fields we're using. Future implementations should try to be backwards compatible with earlier + * versions, and earlier versions should be forward compatible until the version indicated by + * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code>. If an update makes the format unreadable for + * an earlier version, then its version number should be greater than the + * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code> value set on the earlier one, so that it + * doesn't try to parse the object. + */ + public static final String KEY_FORMAT_VERSION = "de.danoeh.antennapod.core.cast.FormatVersion"; + public static final int FORMAT_VERSION_VALUE = 1; + public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999; + + public static boolean isCastable(Playable media, CastSession castSession) { + if (media == null || castSession == null || castSession.getCastDevice() == null) { + return false; + } + if (media instanceof FeedMedia || media instanceof RemoteMedia) { + String url = media.getStreamUrl(); + if (url == null || url.isEmpty()) { + return false; + } + if (url.startsWith(ContentResolver.SCHEME_CONTENT)) { + return false; // Local feed + } + switch (media.getMediaType()) { + case AUDIO: + return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT); + case VIDEO: + return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT); + default: + return false; + } + } + return false; + } + + /** + * Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}. + * @return {@link Playable} object in a format proper for casting. + */ + public static Playable makeRemoteMedia(MediaInfo media) { + MediaMetadata metadata = media.getMetadata(); + int version = metadata.getInt(KEY_FORMAT_VERSION); + if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) { + Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this" + + "version of AntennaPod CastUtils, curVer=" + FORMAT_VERSION_VALUE + + ", object version=" + version); + return null; + } + List<WebImage> imageList = metadata.getImages(); + String imageUrl = null; + if (!imageList.isEmpty()) { + imageUrl = imageList.get(0).getUrl().toString(); + } + String notes = metadata.getString(KEY_EPISODE_NOTES); + RemoteMedia result = new RemoteMedia(media.getContentId(), + metadata.getString(KEY_EPISODE_IDENTIFIER), + metadata.getString(KEY_FEED_URL), + metadata.getString(MediaMetadata.KEY_SUBTITLE), + metadata.getString(MediaMetadata.KEY_TITLE), + metadata.getString(KEY_EPISODE_LINK), + metadata.getString(MediaMetadata.KEY_ARTIST), + imageUrl, + metadata.getString(KEY_FEED_WEBSITE), + media.getContentType(), + metadata.getDate(MediaMetadata.KEY_RELEASE_DATE).getTime(), + notes); + if (result.getDuration() == 0 && media.getStreamDuration() > 0) { + result.setDuration((int) media.getStreamDuration()); + } + return result; + } + + /** + * Compares a {@link MediaInfo} instance with a {@link FeedMedia} one and evaluates whether they + * represent the same podcast episode. + * + * @param info the {@link MediaInfo} object to be compared. + * @param media the {@link FeedMedia} object to be compared. + * @return <true>true</true> if there's a match, <code>false</code> otherwise. + * + * @see RemoteMedia#equals(Object) + */ + public static boolean matches(MediaInfo info, FeedMedia media) { + if (info == null || media == null) { + return false; + } + if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) { + return false; + } + MediaMetadata metadata = info.getMetadata(); + FeedItem fi = media.getItem(); + if (fi == null || metadata == null + || !TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.getItemIdentifier())) { + return false; + } + Feed feed = fi.getFeed(); + return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.getDownload_url()); + } + + /** + * Compares a {@link MediaInfo} instance with a {@link RemoteMedia} one and evaluates whether they + * represent the same podcast episode. + * + * @param info the {@link MediaInfo} object to be compared. + * @param media the {@link RemoteMedia} object to be compared. + * @return <true>true</true> if there's a match, <code>false</code> otherwise. + * + * @see RemoteMedia#equals(Object) + */ + public static boolean matches(MediaInfo info, RemoteMedia media) { + if (info == null || media == null) { + return false; + } + if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) { + return false; + } + MediaMetadata metadata = info.getMetadata(); + return metadata != null + && TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), media.getEpisodeIdentifier()) + && TextUtils.equals(metadata.getString(KEY_FEED_URL), media.getFeedUrl()); + } + + /** + * Compares a {@link MediaInfo} instance with a {@link Playable} and evaluates whether they + * represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device + * and want to avoid unnecessary conversions. + * + * @param info the {@link MediaInfo} object to be compared. + * @param media the {@link Playable} object to be compared. + * @return <true>true</true> if there's a match, <code>false</code> otherwise. + * + * @see RemoteMedia#equals(Object) + */ + public static boolean matches(MediaInfo info, Playable media) { + if (info == null || media == null) { + return false; + } + if (media instanceof RemoteMedia) { + return matches(info, (RemoteMedia) media); + } + return media instanceof FeedMedia && matches(info, (FeedMedia) media); + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java new file mode 100644 index 000000000..dd408d4a7 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java @@ -0,0 +1,135 @@ +package de.danoeh.antennapod.playback.cast; + +import android.net.Uri; +import android.text.TextUtils; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.common.images.WebImage; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.playback.RemoteMedia; +import java.util.Calendar; + +public class MediaInfoCreator { + public static MediaInfo from(RemoteMedia media) { + MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + + metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle()); + metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle()); + if (!TextUtils.isEmpty(media.getImageLocation())) { + metadata.addImage(new WebImage(Uri.parse(media.getImageLocation()))); + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(media.getPubDate()); + metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar); + if (!TextUtils.isEmpty(media.getFeedAuthor())) { + metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor()); + } + if (!TextUtils.isEmpty(media.getFeedUrl())) { + metadata.putString(CastUtils.KEY_FEED_URL, media.getFeedUrl()); + } + if (!TextUtils.isEmpty(media.getFeedLink())) { + metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.getFeedLink()); + } + if (!TextUtils.isEmpty(media.getEpisodeIdentifier())) { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()); + } else { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getDownloadUrl()); + } + if (!TextUtils.isEmpty(media.getEpisodeLink())) { + metadata.putString(CastUtils.KEY_EPISODE_LINK, media.getEpisodeLink()); + } + String notes = media.getNotes(); + if (notes != null) { + metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes); + } + // Default id value + metadata.putInt(CastUtils.KEY_MEDIA_ID, 0); + metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE); + metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()); + + MediaInfo.Builder builder = new MediaInfo.Builder(media.getDownloadUrl()) + .setContentType(media.getMimeType()) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setMetadata(metadata); + if (media.getDuration() > 0) { + builder.setStreamDuration(media.getDuration()); + } + return builder.build(); + } + + /** + * Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device. + * Before using this method, one should make sure isCastable(Playable) returns + * {@code true}. This method should not run on the main thread. + * + * @param media The {@link FeedMedia} object to be converted. + * @return {@link MediaInfo} object in a format proper for casting. + */ + public static MediaInfo from(FeedMedia media) { + if (media == null) { + return null; + } + MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + if (media.getItem() == null) { + throw new IllegalStateException("item is null"); + //media.setItem(DBReader.getFeedItem(media.getItemId())); + } + FeedItem feedItem = media.getItem(); + if (feedItem != null) { + metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle()); + String subtitle = media.getFeedTitle(); + if (subtitle != null) { + metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle); + } + + // Manual because cast does not support embedded images + String url = feedItem.getImageUrl() == null ? feedItem.getFeed().getImageUrl() : feedItem.getImageUrl(); + if (!TextUtils.isEmpty(url)) { + metadata.addImage(new WebImage(Uri.parse(url))); + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(media.getItem().getPubDate()); + metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar); + Feed feed = feedItem.getFeed(); + if (feed != null) { + if (!TextUtils.isEmpty(feed.getAuthor())) { + metadata.putString(MediaMetadata.KEY_ARTIST, feed.getAuthor()); + } + if (!TextUtils.isEmpty(feed.getDownload_url())) { + metadata.putString(CastUtils.KEY_FEED_URL, feed.getDownload_url()); + } + if (!TextUtils.isEmpty(feed.getLink())) { + metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.getLink()); + } + } + if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier()); + } else { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()); + } + if (!TextUtils.isEmpty(feedItem.getLink())) { + metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.getLink()); + } + } + // This field only identifies the id on the device that has the original version. + // Idea is to perhaps, on a first approach, check if the version on the local DB with the + // same id matches the remote object, and if not then search for episode and feed identifiers. + // This at least should make media recognition for a single device much quicker. + metadata.putInt(CastUtils.KEY_MEDIA_ID, ((Long) media.getIdentifier()).intValue()); + // A way to identify different casting media formats in case we change it in the future and + // senders with different versions share a casting device. + metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE); + metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()); + + MediaInfo.Builder builder = new MediaInfo.Builder(media.getStreamUrl()) + .setContentType(media.getMime_type()) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setMetadata(metadata); + if (media.getDuration() > 0) { + builder.setStreamDuration(media.getDuration()); + } + return builder.build(); + } +} |