diff options
author | ByteHamster <ByteHamster@users.noreply.github.com> | 2021-11-28 22:19:14 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-11-28 22:19:14 +0100 |
commit | f0100e61ac633516082ea112363132c99f7c0b7a (patch) | |
tree | f7598c0cee85780b409ab895a8041d1607eec312 /playback | |
parent | af2835c59dcb0473aba7a48b38f5abe28dca34d3 (diff) | |
download | AntennaPod-f0100e61ac633516082ea112363132c99f7c0b7a.zip |
Chromecast rework (#5518)
Diffstat (limited to 'playback')
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> |