summaryrefslogtreecommitdiff
path: root/playback/cast
diff options
context:
space:
mode:
authorByteHamster <ByteHamster@users.noreply.github.com>2021-11-28 22:19:14 +0100
committerGitHub <noreply@github.com>2021-11-28 22:19:14 +0100
commitf0100e61ac633516082ea112363132c99f7c0b7a (patch)
treef7598c0cee85780b409ab895a8041d1607eec312 /playback/cast
parentaf2835c59dcb0473aba7a48b38f5abe28dca34d3 (diff)
downloadAntennaPod-f0100e61ac633516082ea112363132c99f7c0b7a.zip
Chromecast rework (#5518)
Diffstat (limited to 'playback/cast')
-rw-r--r--playback/cast/README.md3
-rw-r--r--playback/cast/build.gradle17
-rw-r--r--playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java17
-rw-r--r--playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java17
-rw-r--r--playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java15
-rw-r--r--playback/cast/src/main/AndroidManifest.xml1
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java35
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java26
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java567
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java69
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java181
-rw-r--r--playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java135
-rw-r--r--playback/cast/src/play/res/menu/cast_button.xml11
13 files changed, 1094 insertions, 0 deletions
diff --git a/playback/cast/README.md b/playback/cast/README.md
new file mode 100644
index 000000000..29eb8eacd
--- /dev/null
+++ b/playback/cast/README.md
@@ -0,0 +1,3 @@
+# :playback:cast
+
+This module provides Chromecast support for the Google Play version of the app.
diff --git a/playback/cast/build.gradle b/playback/cast/build.gradle
new file mode 100644
index 000000000..c51354838
--- /dev/null
+++ b/playback/cast/build.gradle
@@ -0,0 +1,17 @@
+apply plugin: "com.android.library"
+apply from: "../../common.gradle"
+apply from: "../../playFlavor.gradle"
+
+dependencies {
+ implementation project(':event')
+ implementation project(':model')
+ implementation project(':playback:base')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ annotationProcessor "org.greenrobot:eventbus-annotation-processor:$eventbusVersion"
+
+ playApi 'androidx.mediarouter:mediarouter:1.2.5'
+ playApi 'com.google.android.gms:play-services-cast-framework:20.0.0'
+}
diff --git a/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java
new file mode 100644
index 000000000..36524b236
--- /dev/null
+++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java
@@ -0,0 +1,17 @@
+package de.danoeh.antennapod.playback.cast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import android.view.Menu;
+
+/**
+ * Activity that allows for showing the MediaRouter button whenever there's a cast device in the
+ * network.
+ */
+public abstract class CastEnabledActivity extends AppCompatActivity {
+ public static final String TAG = "CastEnabledActivity";
+
+ public final void requestCastButton(Menu menu) {
+ // no-op
+ }
+}
diff --git a/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java
new file mode 100644
index 000000000..7f5e0f2ab
--- /dev/null
+++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java
@@ -0,0 +1,17 @@
+package de.danoeh.antennapod.playback.cast;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+
+/**
+ * Stub implementation of CastPsmp for Free build flavour
+ */
+public class CastPsmp {
+ @Nullable
+ public static PlaybackServiceMediaPlayer getInstanceIfConnected(@NonNull Context context,
+ @NonNull PlaybackServiceMediaPlayer.PSMPCallback callback) {
+ return null;
+ }
+}
diff --git a/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java
new file mode 100644
index 000000000..60cc7dd2c
--- /dev/null
+++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java
@@ -0,0 +1,15 @@
+package de.danoeh.antennapod.playback.cast;
+
+import android.content.Context;
+
+public class CastStateListener {
+
+ public CastStateListener(Context context) {
+ }
+
+ public void destroy() {
+ }
+
+ public void onSessionStartedOrEnded() {
+ }
+}
diff --git a/playback/cast/src/main/AndroidManifest.xml b/playback/cast/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..58c2b9396
--- /dev/null
+++ b/playback/cast/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.playback.cast" />
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();
+ }
+}
diff --git a/playback/cast/src/play/res/menu/cast_button.xml b/playback/cast/src/play/res/menu/cast_button.xml
new file mode 100644
index 000000000..6e65bce18
--- /dev/null
+++ b/playback/cast/src/play/res/menu/cast_button.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/media_route_menu_item"
+ android:title=""
+ app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
+ app:showAsAction="always" />
+
+</menu>