From f0100e61ac633516082ea112363132c99f7c0b7a Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Sun, 28 Nov 2021 22:19:14 +0100 Subject: Chromecast rework (#5518) --- playback/base/README.md | 3 + playback/base/build.gradle | 10 + playback/base/src/main/AndroidManifest.xml | 1 + .../playback/base/PlaybackServiceMediaPlayer.java | 386 +++++++++++++++++++++ .../antennapod/playback/base/PlayerStatus.java | 33 ++ .../playback/base/RewindAfterPauseUtils.java | 47 +++ .../playback/base/RewindAfterPauseUtilTest.java | 56 +++ 7 files changed, 536 insertions(+) create mode 100644 playback/base/README.md create mode 100644 playback/base/build.gradle create mode 100644 playback/base/src/main/AndroidManifest.xml create mode 100644 playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java create mode 100644 playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java create mode 100644 playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java create mode 100644 playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java (limited to 'playback/base') 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 @@ + 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. + *

+ * States: + * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * This method is executed on an internal executor service. + */ + public abstract void prepare(); + + /** + * Resets the media player and moves it into INITIALIZED state. + *

+ * 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. + *

+ * 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 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 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: + *

+ * + * @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). + *

+ * 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). + *

+ * 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. + *

+ * 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); + } +} -- cgit v1.2.3