summaryrefslogtreecommitdiff
path: root/playback
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
parentaf2835c59dcb0473aba7a48b38f5abe28dca34d3 (diff)
downloadAntennaPod-f0100e61ac633516082ea112363132c99f7c0b7a.zip
Chromecast rework (#5518)
Diffstat (limited to 'playback')
-rw-r--r--playback/README.md3
-rw-r--r--playback/base/README.md3
-rw-r--r--playback/base/build.gradle10
-rw-r--r--playback/base/src/main/AndroidManifest.xml1
-rw-r--r--playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java386
-rw-r--r--playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java33
-rw-r--r--playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java47
-rw-r--r--playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java56
-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
21 files changed, 1633 insertions, 0 deletions
diff --git a/playback/README.md b/playback/README.md
new file mode 100644
index 000000000..0709ac2c6
--- /dev/null
+++ b/playback/README.md
@@ -0,0 +1,3 @@
+# :playback
+
+This folder contains modules that deal with media playback.
diff --git a/playback/base/README.md b/playback/base/README.md
new file mode 100644
index 000000000..281a799f1
--- /dev/null
+++ b/playback/base/README.md
@@ -0,0 +1,3 @@
+# :playback:base
+
+This module provides the basic interfaces for a PlaybackServiceMediaPlayer.
diff --git a/playback/base/build.gradle b/playback/base/build.gradle
new file mode 100644
index 000000000..73c320703
--- /dev/null
+++ b/playback/base/build.gradle
@@ -0,0 +1,10 @@
+apply plugin: "com.android.library"
+apply from: "../../common.gradle"
+
+dependencies {
+ implementation project(':model')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+
+ testImplementation 'junit:junit:4.13'
+}
diff --git a/playback/base/src/main/AndroidManifest.xml b/playback/base/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..c6a44a212
--- /dev/null
+++ b/playback/base/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.playback.base" />
diff --git a/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java
new file mode 100644
index 000000000..d03695896
--- /dev/null
+++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java
@@ -0,0 +1,386 @@
+package de.danoeh.antennapod.playback.base;
+
+import android.content.Context;
+import android.media.AudioManager;
+import android.net.wifi.WifiManager;
+import androidx.annotation.NonNull;
+import android.util.Log;
+import android.util.Pair;
+import android.view.SurfaceHolder;
+
+import java.util.List;
+import java.util.concurrent.Future;
+
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.model.playback.Playable;
+
+
+/*
+ * An inconvenience of an implementation like this is that some members and methods that once were
+ * private are now protected, allowing for access from classes of the same package, namely
+ * PlaybackService. A workaround would be to move this to a dedicated package.
+ */
+/**
+ * Abstract class that allows for different implementations of the PlaybackServiceMediaPlayer for local
+ * and remote (cast devices) playback.
+ */
+public abstract class PlaybackServiceMediaPlayer {
+ private static final String TAG = "PlaybackSvcMediaPlayer";
+
+ /**
+ * Return value of some PSMP methods if the method call failed.
+ */
+ public static final int INVALID_TIME = -1;
+
+ private volatile PlayerStatus oldPlayerStatus;
+ protected volatile PlayerStatus playerStatus;
+
+ /**
+ * A wifi-lock that is acquired if the media file is being streamed.
+ */
+ private WifiManager.WifiLock wifiLock;
+
+ protected final PSMPCallback callback;
+ protected final Context context;
+
+ protected PlaybackServiceMediaPlayer(@NonNull Context context,
+ @NonNull PSMPCallback callback){
+ this.context = context;
+ this.callback = callback;
+
+ playerStatus = PlayerStatus.STOPPED;
+ }
+
+ /**
+ * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
+ * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
+ * not do anything.
+ * Whether playback starts immediately depends on the given parameters. See below for more details.
+ * <p/>
+ * States:
+ * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
+ * <p/>
+ * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
+ * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
+ * <p/>
+ * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
+ * will enter the ERROR state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ *
+ * @param playable The Playable object that is supposed to be played. This parameter must not be null.
+ * @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via
+ * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
+ * the Android MediaPlayer via getStreamUrl.
+ * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
+ * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
+ * for playback immediately (see 'prepareImmediately' parameter for more details)
+ * @param prepareImmediately Set to true if the method should also prepare the episode for playback.
+ */
+ public abstract void playMediaObject(@NonNull Playable playable, boolean stream, boolean startWhenPrepared, boolean prepareImmediately);
+
+ /**
+ * Resumes playback if the PSMP object is in PREPARED or PAUSED state. If the PSMP object is in an invalid state.
+ * nothing will happen.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public abstract void resume();
+
+ /**
+ * Saves the current position and pauses playback. Note that, if audiofocus
+ * is abandoned, the lockscreen controls will also disapear.
+ * <p/>
+ * This method is executed on an internal executor service.
+ *
+ * @param abandonFocus is true if the service should release audio focus
+ * @param reinit is true if service should reinit after pausing if the media
+ * file is being streamed
+ */
+ public abstract void pause(boolean abandonFocus, boolean reinit);
+
+ /**
+ * Prepared media player for playback if the service is in the INITALIZED
+ * state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public abstract void prepare();
+
+ /**
+ * Resets the media player and moves it into INITIALIZED state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public abstract void reinit();
+
+ /**
+ * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing.
+ * Invalid time values (< 0) will be ignored.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public abstract void seekTo(int t);
+
+ /**
+ * Seek a specific position from the current position
+ *
+ * @param d offset from current position (positive or negative)
+ */
+ public abstract void seekDelta(int d);
+
+ /**
+ * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved.
+ */
+ public abstract int getDuration();
+
+ /**
+ * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved.
+ */
+ public abstract int getPosition();
+
+ public abstract boolean isStartWhenPrepared();
+
+ public abstract void setStartWhenPrepared(boolean startWhenPrepared);
+
+ /**
+ * Sets the playback parameters.
+ * - Speed
+ * - SkipSilence (ExoPlayer only)
+ * This method is executed on an internal executor service.
+ */
+ public abstract void setPlaybackParams(final float speed, final boolean skipSilence);
+
+ /**
+ * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned.
+ */
+ public abstract float getPlaybackSpeed();
+
+ /**
+ * Sets the playback volume.
+ * This method is executed on an internal executor service.
+ */
+ public abstract void setVolume(float volumeLeft, float volumeRight);
+
+ /**
+ * Returns true if the mediaplayer can mix stereo down to mono
+ */
+ public abstract boolean canDownmix();
+
+ public abstract void setDownmix(boolean enable);
+
+ public abstract MediaType getCurrentMediaType();
+
+ public abstract boolean isStreaming();
+
+ /**
+ * Releases internally used resources. This method should only be called when the object is not used anymore.
+ */
+ public abstract void shutdown();
+
+ /**
+ * Releases internally used resources. This method should only be called when the object is not used anymore.
+ * This method is executed on an internal executor service.
+ */
+ public abstract void shutdownQuietly();
+
+ public abstract void setVideoSurface(SurfaceHolder surface);
+
+ public abstract void resetVideoSurface();
+
+ /**
+ * Return width and height of the currently playing video as a pair.
+ *
+ * @return Width and height as a Pair or null if the video size could not be determined. The method might still
+ * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return
+ * invalid values.
+ */
+ public abstract Pair<Integer, Integer> getVideoSize();
+
+ /**
+ * Returns a PSMInfo object that contains information about the current state of the PSMP object.
+ *
+ * @return The PSMPInfo object.
+ */
+ public final synchronized PSMPInfo getPSMPInfo() {
+ return new PSMPInfo(oldPlayerStatus, playerStatus, getPlayable());
+ }
+
+ /**
+ * Returns the current status, if you need the media and the player status together, you should
+ * use getPSMPInfo() to make sure they're properly synchronized. Otherwise a race condition
+ * could result in nonsensical results (like a status of PLAYING, but a null playable)
+ * @return the current player status
+ */
+ public synchronized PlayerStatus getPlayerStatus() {
+ return playerStatus;
+ }
+
+ /**
+ * Returns the current media, if you need the media and the player status together, you should
+ * use getPSMPInfo() to make sure they're properly synchronized. Otherwise a race condition
+ * could result in nonsensical results (like a status of PLAYING, but a null playable)
+ * @return the current media. May be null
+ */
+ public abstract Playable getPlayable();
+
+ protected abstract void setPlayable(Playable playable);
+
+ public abstract List<String> getAudioTracks();
+
+ public abstract void setAudioTrack(int track);
+
+ public abstract int getSelectedAudioTrack();
+
+ public void skip() {
+ endPlayback(false, true, true, true);
+ }
+
+ /**
+ * Ends playback of current media (if any) and moves into INDETERMINATE state, unless
+ * {@param toStoppedState} is set to true, in which case it moves into STOPPED state.
+ *
+ * @see #endPlayback(boolean, boolean, boolean, boolean)
+ */
+ public Future<?> stopPlayback(boolean toStoppedState) {
+ return endPlayback(false, false, false, toStoppedState);
+ }
+
+ /**
+ * Internal method that handles end of playback.
+ *
+ * Currently, it has 5 use cases:
+ * <ul>
+ * <li>Media playback has completed: call with (true, false, true, true)</li>
+ * <li>User asks to skip to next episode: call with (false, true, true, true)</li>
+ * <li>Skipping to next episode due to playback error: call with (false, false, true, true)</li>
+ * <li>Stopping the media player: call with (false, false, false, true)</li>
+ * <li>We want to change the media player implementation: call with (false, false, false, false)</li>
+ * </ul>
+ *
+ * @param hasEnded If true, we assume the current media's playback has ended, for
+ * purposes of post playback processing.
+ * @param wasSkipped Whether the user chose to skip the episode (by pressing the skip
+ * button).
+ * @param shouldContinue If true, the media player should try to load, and possibly play,
+ * the next item, based on the user preferences and whether such item
+ * exists.
+ * @param toStoppedState If true, the playback state gets set to STOPPED if the media player
+ * is not loading/playing after this call, and the UI will reflect that.
+ * Only relevant if {@param shouldContinue} is set to false, otherwise
+ * this method's behavior defaults as if this parameter was true.
+ *
+ * @return a Future, just for the purpose of tracking its execution.
+ */
+ protected abstract Future<?> endPlayback(boolean hasEnded, boolean wasSkipped,
+ boolean shouldContinue, boolean toStoppedState);
+
+ /**
+ * @return {@code true} if the WifiLock feature should be used, {@code false} otherwise.
+ */
+ protected abstract boolean shouldLockWifi();
+
+ public abstract boolean isCasting();
+
+ protected final synchronized void acquireWifiLockIfNecessary() {
+ if (shouldLockWifi()) {
+ if (wifiLock == null) {
+ wifiLock = ((WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE))
+ .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG);
+ wifiLock.setReferenceCounted(false);
+ }
+ wifiLock.acquire();
+ }
+ }
+
+ protected final synchronized void releaseWifiLockIfNecessary() {
+ if (wifiLock != null && wifiLock.isHeld()) {
+ wifiLock.release();
+ }
+ }
+
+ /**
+ * Sets the player status of the PSMP object. PlayerStatus and media attributes have to be set at the same time
+ * so that getPSMPInfo can't return an invalid state (e.g. status is PLAYING, but media is null).
+ * <p/>
+ * This method will notify the callback about the change of the player status (even if the new status is the same
+ * as the old one).
+ * <p/>
+ * It will also call {@link PSMPCallback#onPlaybackPause(Playable, int)} or {@link PSMPCallback#onPlaybackStart(Playable, int)}
+ * depending on the status change.
+ *
+ * @param newStatus The new PlayerStatus. This must not be null.
+ * @param newMedia The new playable object of the PSMP object. This can be null.
+ * @param position The position to be set to the current Playable object in case playback started or paused.
+ * Will be ignored if given the value of {@link #INVALID_TIME}.
+ */
+ protected final synchronized void setPlayerStatus(@NonNull PlayerStatus newStatus,
+ Playable newMedia, int position) {
+ Log.d(TAG, this.getClass().getSimpleName() + ": Setting player status to " + newStatus);
+
+ this.oldPlayerStatus = playerStatus;
+ this.playerStatus = newStatus;
+ setPlayable(newMedia);
+
+ if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) {
+ if (oldPlayerStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING) {
+ callback.onPlaybackPause(newMedia, position);
+ } else if (oldPlayerStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING) {
+ callback.onPlaybackStart(newMedia, position);
+ }
+ }
+
+ callback.statusChanged(new PSMPInfo(oldPlayerStatus, playerStatus, getPlayable()));
+ }
+
+ public boolean isAudioChannelInUse() {
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ return (audioManager.getMode() != AudioManager.MODE_NORMAL || audioManager.isMusicActive());
+ }
+
+ /**
+ * @see #setPlayerStatus(PlayerStatus, Playable, int)
+ */
+ protected final void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) {
+ setPlayerStatus(newStatus, newMedia, INVALID_TIME);
+ }
+
+ public interface PSMPCallback {
+ void statusChanged(PSMPInfo newInfo);
+
+ void shouldStop();
+
+ void onMediaChanged(boolean reloadUI);
+
+ void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext);
+
+ void onPlaybackStart(@NonNull Playable playable, int position);
+
+ void onPlaybackPause(Playable playable, int position);
+
+ Playable getNextInQueue(Playable currentMedia);
+
+ @Nullable
+ Playable findMedia(@NonNull String url);
+
+ void onPlaybackEnded(MediaType mediaType, boolean stopPlaying);
+
+ void ensureMediaInfoLoaded(@NonNull Playable media);
+ }
+
+ /**
+ * Holds information about a PSMP object.
+ */
+ public static class PSMPInfo {
+ public final PlayerStatus oldPlayerStatus;
+ public PlayerStatus playerStatus;
+ public Playable playable;
+
+ public PSMPInfo(PlayerStatus oldPlayerStatus, PlayerStatus playerStatus, Playable playable) {
+ this.oldPlayerStatus = oldPlayerStatus;
+ this.playerStatus = playerStatus;
+ this.playable = playable;
+ }
+ }
+}
diff --git a/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java
new file mode 100644
index 000000000..d995ae21f
--- /dev/null
+++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java
@@ -0,0 +1,33 @@
+package de.danoeh.antennapod.playback.base;
+
+public enum PlayerStatus {
+ INDETERMINATE(0), // player is currently changing its state, listeners should wait until the state is left
+ ERROR(-1),
+ PREPARING(19),
+ PAUSED(30),
+ PLAYING(40),
+ STOPPED(5),
+ PREPARED(20),
+ SEEKING(29),
+ INITIALIZING(9), // playback service is loading the Playable's metadata
+ INITIALIZED(10); // playback service was started, data source of media player was set
+
+ private final int statusValue;
+ private static final PlayerStatus[] fromOrdinalLookup;
+
+ static {
+ fromOrdinalLookup = PlayerStatus.values();
+ }
+
+ PlayerStatus(int val) {
+ statusValue = val;
+ }
+
+ public static PlayerStatus fromOrdinal(int o) {
+ return fromOrdinalLookup[o];
+ }
+
+ public boolean isAtLeast(PlayerStatus other) {
+ return other == null || this.statusValue >= other.statusValue;
+ }
+}
diff --git a/playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java
new file mode 100644
index 000000000..7d694f38b
--- /dev/null
+++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java
@@ -0,0 +1,47 @@
+package de.danoeh.antennapod.playback.base;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * This class calculates the proper rewind time after the pause and resume.
+ * <p>
+ * User might loose context if he/she pauses and resumes the media after longer time.
+ * Media file should be "rewinded" x seconds after user resumes the playback.
+ */
+public class RewindAfterPauseUtils {
+ private RewindAfterPauseUtils(){}
+
+ public static final long ELAPSED_TIME_FOR_SHORT_REWIND = TimeUnit.MINUTES.toMillis(1);
+ public static final long ELAPSED_TIME_FOR_MEDIUM_REWIND = TimeUnit.HOURS.toMillis(1);
+ public static final long ELAPSED_TIME_FOR_LONG_REWIND = TimeUnit.DAYS.toMillis(1);
+
+ public static final long SHORT_REWIND = TimeUnit.SECONDS.toMillis(3);
+ public static final long MEDIUM_REWIND = TimeUnit.SECONDS.toMillis(10);
+ public static final long LONG_REWIND = TimeUnit.SECONDS.toMillis(20);
+
+ /**
+ * @param currentPosition current position in a media file in ms
+ * @param lastPlayedTime timestamp when was media paused
+ * @return new rewinded position for playback in milliseconds
+ */
+ public static int calculatePositionWithRewind(int currentPosition, long lastPlayedTime) {
+ if (currentPosition > 0 && lastPlayedTime > 0) {
+ long elapsedTime = System.currentTimeMillis() - lastPlayedTime;
+ long rewindTime = 0;
+
+ if (elapsedTime > ELAPSED_TIME_FOR_LONG_REWIND) {
+ rewindTime = LONG_REWIND;
+ } else if (elapsedTime > ELAPSED_TIME_FOR_MEDIUM_REWIND) {
+ rewindTime = MEDIUM_REWIND;
+ } else if (elapsedTime > ELAPSED_TIME_FOR_SHORT_REWIND) {
+ rewindTime = SHORT_REWIND;
+ }
+
+ int newPosition = currentPosition - (int) rewindTime;
+
+ return Math.max(newPosition, 0);
+ } else {
+ return currentPosition;
+ }
+ }
+}
diff --git a/playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java b/playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java
new file mode 100644
index 000000000..b122971b2
--- /dev/null
+++ b/playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java
@@ -0,0 +1,56 @@
+package de.danoeh.antennapod.playback.base;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Tests for {@link RewindAfterPauseUtils}.
+ */
+public class RewindAfterPauseUtilTest {
+
+ @Test
+ public void testCalculatePositionWithRewindNoRewind() {
+ final int ORIGINAL_POSITION = 10000;
+ long lastPlayed = System.currentTimeMillis();
+ int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
+
+ assertEquals(ORIGINAL_POSITION, position);
+ }
+
+ @Test
+ public void testCalculatePositionWithRewindSmallRewind() {
+ final int ORIGINAL_POSITION = 10000;
+ long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_SHORT_REWIND - 1000;
+ int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
+
+ assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.SHORT_REWIND, position);
+ }
+
+ @Test
+ public void testCalculatePositionWithRewindMediumRewind() {
+ final int ORIGINAL_POSITION = 10000;
+ long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_MEDIUM_REWIND - 1000;
+ int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
+
+ assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.MEDIUM_REWIND, position);
+ }
+
+ @Test
+ public void testCalculatePositionWithRewindLongRewind() {
+ final int ORIGINAL_POSITION = 30000;
+ long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000;
+ int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
+
+ assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.LONG_REWIND, position);
+ }
+
+ @Test
+ public void testCalculatePositionWithRewindNegativeNumber() {
+ final int ORIGINAL_POSITION = 100;
+ long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000;
+ int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
+
+ assertEquals(0, position);
+ }
+}
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>