summaryrefslogtreecommitdiff
path: root/playback/base
diff options
context:
space:
mode:
Diffstat (limited to 'playback/base')
-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
7 files changed, 536 insertions, 0 deletions
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);
+ }
+}