diff options
105 files changed, 5937 insertions, 1700 deletions
diff --git a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java index 7dac89e9b..195dccdda 100644 --- a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java +++ b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java @@ -21,13 +21,14 @@ import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.core.service.playback.LocalPSMP; import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.core.storage.PodDBAdapter; import de.danoeh.antennapod.core.util.playback.Playable; import de.test.antennapod.util.service.download.HTTPBin; /** - * Test class for PlaybackServiceMediaPlayer + * Test class for LocalPSMP */ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { private static final String TAG = "PlaybackServiceMediaPlayerTest"; @@ -85,7 +86,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { assertEquals(0, httpServer.serveFile(dest)); } - private void checkPSMPInfo(PlaybackServiceMediaPlayer.PSMPInfo info) { + private void checkPSMPInfo(LocalPSMP.PSMPInfo info) { try { switch (info.playerStatus) { case PLAYING: @@ -111,7 +112,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { public void testInit() { final Context c = getInstrumentation().getTargetContext(); - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, defaultCallback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, defaultCallback); psmp.shutdown(); } @@ -139,7 +140,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(2); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -175,7 +176,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -185,7 +186,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -195,12 +196,12 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, null); psmp.playMediaObject(p, true, false, false); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -218,7 +219,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(2); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -254,7 +255,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -264,7 +265,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -274,11 +275,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, null); psmp.playMediaObject(p, true, true, false); @@ -297,7 +298,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(4); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -336,7 +337,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -346,7 +347,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -356,11 +357,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, null); psmp.playMediaObject(p, true, false, true); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -377,7 +378,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(5); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -419,7 +420,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -429,7 +430,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -439,12 +440,12 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, null); psmp.playMediaObject(p, true, true, true); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -460,7 +461,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(2); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -496,7 +497,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -506,7 +507,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -516,12 +517,12 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); psmp.playMediaObject(p, false, false, false); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -538,7 +539,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(2); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -574,7 +575,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -584,7 +585,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -594,11 +595,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); psmp.playMediaObject(p, false, true, false); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -615,7 +616,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(4); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -654,7 +655,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -664,7 +665,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -674,11 +675,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); psmp.playMediaObject(p, false, false, true); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -694,7 +695,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(5); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { try { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) @@ -737,7 +738,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -747,7 +748,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -757,11 +758,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); psmp.playMediaObject(p, false, true, true); boolean res = countDownLatch.await(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -775,7 +776,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { private final PlaybackServiceMediaPlayer.PSMPCallback defaultCallback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { checkPSMPInfo(newInfo); } @@ -795,7 +796,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -805,7 +806,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { return false; } + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @Override public boolean onMediaPlayerError(Object inObj, int what, int extra) { @@ -813,7 +814,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; @@ -825,7 +826,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) { if (assertionError == null) @@ -873,7 +874,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -883,7 +884,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -895,11 +896,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); if (initialState == PlayerStatus.PLAYING) { psmp.playMediaObject(p, stream, true, true); @@ -956,7 +957,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) { if (assertionError == null) @@ -988,7 +989,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -998,7 +999,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -1011,11 +1012,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); if (initialState == PlayerStatus.PREPARED || initialState == PlayerStatus.PLAYING || initialState == PlayerStatus.PAUSED) { boolean startWhenPrepared = (initialState != PlayerStatus.PREPARED); psmp.playMediaObject(writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL), false, startWhenPrepared, true); @@ -1049,7 +1050,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(latchCount); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) { if (assertionError == null) @@ -1080,7 +1081,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -1090,7 +1091,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -1102,11 +1103,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); if (initialState == PlayerStatus.INITIALIZED || initialState == PlayerStatus.PLAYING @@ -1154,7 +1155,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { final CountDownLatch countDownLatch = new CountDownLatch(latchCount); PlaybackServiceMediaPlayer.PSMPCallback callback = new PlaybackServiceMediaPlayer.PSMPCallback() { @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + public void statusChanged(LocalPSMP.PSMPInfo newInfo) { checkPSMPInfo(newInfo); if (newInfo.playerStatus == PlayerStatus.ERROR) { if (assertionError == null) @@ -1184,7 +1185,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public void updateMediaSessionMetadata(Playable p) { + public void reloadUI() { } @@ -1194,7 +1195,7 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, int resourceId) { return false; } @@ -1206,11 +1207,11 @@ public class PlaybackServiceMediaPlayerTest extends InstrumentationTestCase { } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { + public boolean endPlayback(Playable p, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { return false; } }; - PlaybackServiceMediaPlayer psmp = new PlaybackServiceMediaPlayer(c, callback); + PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(PLAYABLE_FILE_URL, PLAYABLE_LOCAL_URL); boolean prepareImmediately = initialState != PlayerStatus.INITIALIZED; boolean startImmediately = initialState != PlayerStatus.PREPARED; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 005ba604f..65c9f8ec4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,10 @@ android:name="com.google.android.backup.api_key" android:value="AEdPqrEAAAAI3a05VToCTlqBymJrbFGaKQMvF-bBAuLsOdavBA"/> + <meta-data + android:name="com.google.android.gms.version" + android:value="@integer/google_play_services_version" /> + <activity android:name=".activity.MainActivity" android:configChanges="keyboardHidden|orientation" @@ -67,6 +71,13 @@ <data android:mimeType="audio/*"/> </intent-filter> </activity> + <activity + android:name=".activity.CastplayerActivity" + android:launchMode="singleTop"> + <meta-data + android:name="android.support.PARENT_ACTIVITY" + android:value="de.danoeh.antennapod.activity.MainActivity"/> + </activity> <activity android:name=".activity.DownloadAuthenticationActivity" diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java index 292f3d9b6..ca214de9e 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java @@ -1,166 +1,27 @@ package de.danoeh.antennapod.activity; -import android.app.AlertDialog; -import android.content.DialogInterface; import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.os.Build; -import android.support.annotation.Nullable; -import android.support.design.widget.AppBarLayout; -import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentManager; -import android.support.v4.app.FragmentStatePagerAdapter; -import android.support.v4.view.ViewPager; -import android.support.v4.widget.DrawerLayout; -import android.support.v7.app.ActionBarDrawerToggle; -import android.support.v7.widget.Toolbar; +import android.support.v4.view.ViewCompat; import android.text.TextUtils; import android.util.Log; -import android.util.TypedValue; -import android.view.ContextMenu; -import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; -import android.widget.AdapterView; -import android.widget.ListView; -import com.viewpagerindicator.CirclePageIndicator; - -import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.adapter.ChaptersListAdapter; -import de.danoeh.antennapod.adapter.NavListAdapter; -import de.danoeh.antennapod.core.asynctask.FeedRemover; -import de.danoeh.antennapod.core.dialog.ConfirmationDialog; -import de.danoeh.antennapod.core.feed.EventDistributor; -import de.danoeh.antennapod.core.feed.Feed; -import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; -import de.danoeh.antennapod.core.storage.DBReader; -import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.util.playback.ExternalMedia; -import de.danoeh.antennapod.core.util.playback.Playable; -import de.danoeh.antennapod.core.util.playback.PlaybackController; -import de.danoeh.antennapod.fragment.AddFeedFragment; -import de.danoeh.antennapod.fragment.ChaptersFragment; -import de.danoeh.antennapod.fragment.CoverFragment; -import de.danoeh.antennapod.fragment.DownloadsFragment; -import de.danoeh.antennapod.fragment.EpisodesFragment; -import de.danoeh.antennapod.fragment.ItemDescriptionFragment; -import de.danoeh.antennapod.fragment.PlaybackHistoryFragment; -import de.danoeh.antennapod.fragment.QueueFragment; -import de.danoeh.antennapod.fragment.SubscriptionFragment; -import de.danoeh.antennapod.menuhandler.NavDrawerActivity; -import de.danoeh.antennapod.preferences.PreferenceController; -import rx.Observable; -import rx.Subscription; -import rx.android.schedulers.AndroidSchedulers; -import rx.schedulers.Schedulers; +import de.danoeh.antennapod.dialog.VariableSpeedDialog; /** * Activity for playing audio files. */ -public class AudioplayerActivity extends MediaplayerActivity implements NavDrawerActivity { - - private static final int POS_COVER = 0; - private static final int POS_DESCR = 1; - private static final int POS_CHAPTERS = 2; - private static final int NUM_CONTENT_FRAGMENTS = 3; - - final String TAG = "AudioplayerActivity"; - private static final String PREFS = "AudioPlayerActivityPreferences"; - private static final String PREF_KEY_SELECTED_FRAGMENT_POSITION = "selectedFragmentPosition"; - - public static final String[] NAV_DRAWER_TAGS = { - QueueFragment.TAG, - EpisodesFragment.TAG, - SubscriptionFragment.TAG, - DownloadsFragment.TAG, - PlaybackHistoryFragment.TAG, - AddFeedFragment.TAG, - NavListAdapter.SUBSCRIPTION_LIST_TAG - }; +public class AudioplayerActivity extends MediaplayerInfoActivity { + public static final String TAG = "AudioPlayerActivity"; private AtomicBoolean isSetup = new AtomicBoolean(false); - private DrawerLayout drawerLayout; - private NavListAdapter navAdapter; - private ListView navList; - private View navDrawer; - private ActionBarDrawerToggle drawerToggle; - private int mPosition = -1; - - private Playable media; - private ViewPager pager; - private AudioplayerPagerAdapter pagerAdapter; - - private Subscription subscription; - - @Override - protected void onStop() { - super.onStop(); - Log.d(TAG, "onStop()"); - if(pagerAdapter != null) { - pagerAdapter.setController(null); - } - if(subscription != null) { - subscription.unsubscribe(); - } - EventDistributor.getInstance().unregister(contentUpdate); - saveCurrentFragment(); - } - - @Override - public void onDestroy() { - Log.d(TAG, "onDestroy()"); - super.onDestroy(); - // don't risk creating memory leaks - drawerLayout = null; - navAdapter = null; - navList = null; - navDrawer = null; - drawerToggle = null; - pager = null; - pagerAdapter = null; - } - - @Override - protected void chooseTheme() { - setTheme(UserPreferences.getNoTitleTheme()); - } - - private void saveCurrentFragment() { - if(pager == null) { - return; - } - Log.d(TAG, "Saving preferences"); - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - prefs.edit() - .putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, pager.getCurrentItem()) - .commit(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - if(drawerToggle != null) { - drawerToggle.onConfigurationChanged(newConfig); - } - } - - private void loadLastFragment() { - Log.d(TAG, "Restoring instance state"); - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - int lastPosition = prefs.getInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, -1); - pager.setCurrentItem(lastPosition); - } - @Override protected void onResume() { super.onResume(); @@ -177,456 +38,116 @@ public class AudioplayerActivity extends MediaplayerActivity implements NavDrawe launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, true); startService(launchIntent); + } else if (PlaybackService.isCasting()) { + Intent intent = PlaybackService.getPlayerActivityIntent(this); + if (!intent.getComponent().getClassName().equals(AudioplayerActivity.class.getName())) { + saveCurrentFragment(); + finish(); + startActivity(intent); + } } - if(pagerAdapter != null && controller != null && controller.getMedia() != media) { - media = controller.getMedia(); - pagerAdapter.onMediaChanged(media); - pagerAdapter.setController(controller); - } - - EventDistributor.getInstance().register(contentUpdate); - loadData(); } @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - } - - @Override - protected void onAwaitingVideoSurface() { - Log.d(TAG, "onAwaitingVideoSurface was called in audio player -> switching to video player"); - startActivity(new Intent(this, VideoplayerActivity.class)); - } + protected void onReloadNotification(int notificationCode) { + if (notificationCode == PlaybackService.EXTRA_CODE_CAST) { + Log.d(TAG, "ReloadNotification received, switching to Castplayer now"); + saveCurrentFragment(); + finish(); + startActivity(new Intent(this, CastplayerActivity.class)); - @Override - protected void postStatusMsg(int resId) { - if (resId == R.string.player_preparing_msg - || resId == R.string.player_seeking_msg - || resId == R.string.player_buffering_msg) { - // TODO Show progress bar here + } else { + super.onReloadNotification(notificationCode); } } @Override - protected void clearStatusMsg() { - // TODO Hide progress bar here - } - - - @Override - protected void setupGUI() { - if(isSetup.getAndSet(true)) { + protected void updatePlaybackSpeedButton() { + if(butPlaybackSpeed == null) { return; } - super.setupGUI(); - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(""); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - findViewById(R.id.shadow).setVisibility(View.GONE); - AppBarLayout appBarLayout = (AppBarLayout) findViewById(R.id.appBar); - float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()); - appBarLayout.setElevation(px); + if (controller == null) { + butPlaybackSpeed.setVisibility(View.GONE); + return; } - drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); - navList = (ListView) findViewById(R.id.nav_list); - navDrawer = findViewById(R.id.nav_layout); - - drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, R.string.drawer_open, R.string.drawer_close); - drawerToggle.setDrawerIndicatorEnabled(false); - drawerLayout.setDrawerListener(drawerToggle); - - navAdapter = new NavListAdapter(itemAccess, this); - navList.setAdapter(navAdapter); - navList.setOnItemClickListener((parent, view, position, id) -> { - int viewType = parent.getAdapter().getItemViewType(position); - if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER) { - Intent intent = new Intent(AudioplayerActivity.this, MainActivity.class); - intent.putExtra(MainActivity.EXTRA_NAV_TYPE, viewType); - intent.putExtra(MainActivity.EXTRA_NAV_INDEX, position); - startActivity(intent); - } - drawerLayout.closeDrawer(navDrawer); - }); - navList.setOnItemLongClickListener((parent, view, position, id) -> { - if (position < navAdapter.getTags().size()) { - showDrawerPreferencesDialog(); - return true; - } else { - mPosition = position; - return false; - } - }); - registerForContextMenu(navList); - drawerToggle.syncState(); - - findViewById(R.id.nav_settings).setOnClickListener(v -> { - drawerLayout.closeDrawer(navDrawer); - startActivity(new Intent(AudioplayerActivity.this, PreferenceController.getPreferenceActivity())); - }); - - pager = (ViewPager) findViewById(R.id.pager); - pagerAdapter = new AudioplayerPagerAdapter(getSupportFragmentManager(), media); - pagerAdapter.setController(controller); - pager.setAdapter(pagerAdapter); - CirclePageIndicator pageIndicator = (CirclePageIndicator) findViewById(R.id.page_indicator); - pageIndicator.setViewPager(pager); - loadLastFragment(); - pager.onSaveInstanceState(); - } - - @Override - protected void onPositionObserverUpdate() { - super.onPositionObserverUpdate(); - notifyMediaPositionChanged(); + updatePlaybackSpeedButtonText(); + ViewCompat.setAlpha(butPlaybackSpeed, controller.canSetPlaybackSpeed() ? 1.0f : 0.5f); + butPlaybackSpeed.setVisibility(View.VISIBLE); } @Override - protected boolean loadMediaInfo() { - if (!super.loadMediaInfo()) { - return false; - } - if(controller.getMedia() != media) { - media = controller.getMedia(); - pagerAdapter.onMediaChanged(media); + protected void updatePlaybackSpeedButtonText() { + if(butPlaybackSpeed == null) { + return; } - return true; - } - - public void notifyMediaPositionChanged() { - if(pagerAdapter == null) { + if (controller == null) { + butPlaybackSpeed.setVisibility(View.GONE); return; } - ChaptersFragment chaptersFragment = pagerAdapter.getChaptersFragment(); - if(chaptersFragment != null) { - ChaptersListAdapter adapter = (ChaptersListAdapter) chaptersFragment.getListAdapter(); - if (adapter != null) { - adapter.notifyDataSetChanged(); + float speed = 1.0f; + if(controller.canSetPlaybackSpeed()) { + try { + // we can only retrieve the playback speed from the controller/playback service + // once mediaplayer has been initialized + speed = Float.parseFloat(UserPreferences.getPlaybackSpeed()); + } catch (NumberFormatException e) { + Log.e(TAG, Log.getStackTraceString(e)); + UserPreferences.setPlaybackSpeed(String.valueOf(speed)); } } + String speedStr = String.format("%.2fx", speed); + butPlaybackSpeed.setText(speedStr); } @Override - protected void onReloadNotification(int notificationCode) { - if (notificationCode == PlaybackService.EXTRA_CODE_VIDEO) { - Log.d(TAG, "ReloadNotification received, switching to Videoplayer now"); - finish(); - startActivity(new Intent(this, VideoplayerActivity.class)); - - } - } - - @Override - protected void onBufferStart() { - postStatusMsg(R.string.player_buffering_msg); - } - - @Override - protected void onBufferEnd() { - clearStatusMsg(); - } - - public PlaybackController getPlaybackController() { - return controller; - } - - @Override - public boolean isDrawerOpen() { - return drawerLayout != null && navDrawer != null && drawerLayout.isDrawerOpen(navDrawer); - } - - @Override - protected int getContentViewResourceId() { - return R.layout.audioplayer_activity; - } - - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - return drawerToggle != null && drawerToggle.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); - } - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { - super.onCreateContextMenu(menu, v, menuInfo); - if(v.getId() != R.id.nav_list) { - return; - } - AdapterView.AdapterContextMenuInfo adapterInfo = (AdapterView.AdapterContextMenuInfo) menuInfo; - int position = adapterInfo.position; - if(position < navAdapter.getSubscriptionOffset()) { + protected void setupGUI() { + if(isSetup.getAndSet(true)) { return; } - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.nav_feed_context, menu); - Feed feed = navDrawerData.feeds.get(position - navAdapter.getSubscriptionOffset()); - menu.setHeaderTitle(feed.getTitle()); - // episodes are not loaded, so we cannot check if the podcast has new or unplayed ones! - } - - @Override - public boolean onContextItemSelected(MenuItem item) { - final int position = mPosition; - mPosition = -1; // reset - if(position < 0) { - return false; - } - Feed feed = navDrawerData.feeds.get(position - navAdapter.getSubscriptionOffset()); - switch(item.getItemId()) { - case R.id.mark_all_seen_item: - DBWriter.markFeedSeen(feed.getId()); - return true; - case R.id.mark_all_read_item: - DBWriter.markFeedRead(feed.getId()); - return true; - case R.id.remove_item: - final FeedRemover remover = new FeedRemover(this, feed) { - @Override - protected void onPostExecute(Void result) { - super.onPostExecute(result); + super.setupGUI(); + if(butCastDisconnect != null) { + butCastDisconnect.setVisibility(View.GONE); + } + if(butPlaybackSpeed != null) { + butPlaybackSpeed.setOnClickListener(v -> { + if (controller == null) { + return; + } + if (controller.canSetPlaybackSpeed()) { + String[] availableSpeeds = UserPreferences.getPlaybackSpeedArray(); + String currentSpeed = UserPreferences.getPlaybackSpeed(); + + // Provide initial value in case the speed list has changed + // out from under us + // and our current speed isn't in the new list + String newSpeed; + if (availableSpeeds.length > 0) { + newSpeed = availableSpeeds[0]; + } else { + newSpeed = "1.00"; } - }; - ConfirmationDialog conDialog = new ConfirmationDialog(this, - R.string.remove_feed_label, - R.string.feed_delete_confirmation_msg) { - @Override - public void onConfirmButtonPressed( - DialogInterface dialog) { - dialog.dismiss(); - if (controller != null) { - Playable playable = controller.getMedia(); - if (playable != null && playable instanceof FeedMedia) { - FeedMedia media = (FeedMedia) playable; - if (media.getItem().getFeed().getId() == feed.getId()) { - Log.d(TAG, "Currently playing episode is about to be deleted, skipping"); - remover.skipOnCompletion = true; - if(controller.getStatus() == PlayerStatus.PLAYING) { - sendBroadcast(new Intent( - PlaybackService.ACTION_PAUSE_PLAY_CURRENT_EPISODE)); - } - } + + for (int i = 0; i < availableSpeeds.length; i++) { + if (availableSpeeds[i].equals(currentSpeed)) { + if (i == availableSpeeds.length - 1) { + newSpeed = availableSpeeds[0]; + } else { + newSpeed = availableSpeeds[i + 1]; } + break; } - remover.executeAsync(); } - }; - conDialog.createNewDialog().show(); + UserPreferences.setPlaybackSpeed(newSpeed); + controller.setPlaybackSpeed(Float.parseFloat(newSpeed)); + } else { + VariableSpeedDialog.showGetPluginDialog(this); + } + }); + butPlaybackSpeed.setOnLongClickListener(v -> { + VariableSpeedDialog.showDialog(this); return true; - default: - return super.onContextItemSelected(item); + }); + butPlaybackSpeed.setVisibility(View.VISIBLE); } } - - @Override - public void onBackPressed() { - if(isDrawerOpen()) { - drawerLayout.closeDrawer(navDrawer); - } else if (pager == null || pager.getCurrentItem() == 0) { - // If the user is currently looking at the first step, allow the system to handle the - // Back button. This calls finish() on this activity and pops the back stack. - super.onBackPressed(); - } else { - // Otherwise, select the previous step. - pager.setCurrentItem(pager.getCurrentItem() - 1); - } - } - - public void showDrawerPreferencesDialog() { - final List<String> hiddenDrawerItems = UserPreferences.getHiddenDrawerItems(); - String[] navLabels = new String[NAV_DRAWER_TAGS.length]; - final boolean[] checked = new boolean[NAV_DRAWER_TAGS.length]; - for (int i = 0; i < NAV_DRAWER_TAGS.length; i++) { - String tag = NAV_DRAWER_TAGS[i]; - navLabels[i] = navAdapter.getLabel(tag); - if (!hiddenDrawerItems.contains(tag)) { - checked[i] = true; - } - } - - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle(R.string.drawer_preferences); - builder.setMultiChoiceItems(navLabels, checked, (dialog, which, isChecked) -> { - if (isChecked) { - hiddenDrawerItems.remove(NAV_DRAWER_TAGS[which]); - } else { - hiddenDrawerItems.add(NAV_DRAWER_TAGS[which]); - } - }); - builder.setPositiveButton(R.string.confirm_label, (dialog, which) -> { - UserPreferences.setHiddenDrawerItems(hiddenDrawerItems); - }); - builder.setNegativeButton(R.string.cancel_label, null); - builder.create().show(); - } - - private DBReader.NavDrawerData navDrawerData; - - private void loadData() { - subscription = Observable.fromCallable(DBReader::getNavDrawerData) - .subscribeOn(Schedulers.newThread()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - navDrawerData = result; - if (navAdapter != null) { - navAdapter.notifyDataSetChanged(); - } - }, error -> { - Log.e(TAG, Log.getStackTraceString(error)); - }); - } - - - - private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { - - @Override - public void update(EventDistributor eventDistributor, Integer arg) { - if ((EventDistributor.FEED_LIST_UPDATE & arg) != 0) { - Log.d(TAG, "Received contentUpdate Intent."); - loadData(); - } - } - }; - - private final NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() { - @Override - public int getCount() { - if (navDrawerData != null) { - return navDrawerData.feeds.size(); - } else { - return 0; - } - } - - @Override - public Feed getItem(int position) { - if (navDrawerData != null && 0 <= position && position < navDrawerData.feeds.size()) { - return navDrawerData.feeds.get(position); - } else { - return null; - } - } - - @Override - public int getSelectedItemIndex() { - return -1; - } - - @Override - public int getQueueSize() { - return (navDrawerData != null) ? navDrawerData.queueSize : 0; - } - - @Override - public int getNumberOfNewItems() { - return (navDrawerData != null) ? navDrawerData.numNewItems : 0; - } - - @Override - public int getNumberOfDownloadedItems() { - return (navDrawerData != null) ? navDrawerData.numDownloadedItems : 0; - } - - @Override - public int getReclaimableItems() { - return (navDrawerData != null) ? navDrawerData.reclaimableSpace : 0; - } - - @Override - public int getFeedCounter(long feedId) { - return navDrawerData != null ? navDrawerData.feedCounters.get(feedId) : 0; - } - - @Override - public int getFeedCounterSum() { - if(navDrawerData == null) { - return 0; - } - int sum = 0; - for(int counter : navDrawerData.feedCounters.values()) { - sum += counter; - } - return sum; - } - }; - - public interface AudioplayerContentFragment { - void onMediaChanged(Playable media); - } - - private static class AudioplayerPagerAdapter extends FragmentStatePagerAdapter { - - private static final String TAG = "AudioplayerPagerAdapter"; - - private Playable media; - private PlaybackController controller; - - public AudioplayerPagerAdapter(FragmentManager fm, Playable media) { - super(fm); - this.media = media; - } - - private CoverFragment coverFragment; - private ItemDescriptionFragment itemDescriptionFragment; - private ChaptersFragment chaptersFragment; - - public void onMediaChanged(Playable media) { - this.media = media; - if(coverFragment != null) { - coverFragment.onMediaChanged(media); - } - if(itemDescriptionFragment != null) { - itemDescriptionFragment.onMediaChanged(media); - } - if(chaptersFragment != null) { - chaptersFragment.onMediaChanged(media); - } - } - - public void setController(PlaybackController controller) { - this.controller = controller; - if(chaptersFragment != null) { - chaptersFragment.setController(controller); - } - } - - @Nullable - public ChaptersFragment getChaptersFragment() { - return chaptersFragment; - } - - @Override - public Fragment getItem(int position) { - Log.d(TAG, "getItem(" + position + ")"); - switch (position) { - case POS_COVER: - if(coverFragment == null) { - coverFragment = CoverFragment.newInstance(media); - } - return coverFragment; - case POS_DESCR: - if(itemDescriptionFragment == null) { - itemDescriptionFragment = ItemDescriptionFragment.newInstance(media, true, true); - } - return itemDescriptionFragment; - case POS_CHAPTERS: - if(chaptersFragment == null) { - chaptersFragment = ChaptersFragment.newInstance(media); - chaptersFragment.setController(controller); - } - return chaptersFragment; - default: - return null; - } - } - - @Override - public int getCount() { - return NUM_CONTENT_FRAGMENTS; - } - } - } diff --git a/app/src/main/java/de/danoeh/antennapod/activity/CastEnabledActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/CastEnabledActivity.java new file mode 100644 index 000000000..b8856c295 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/CastEnabledActivity.java @@ -0,0 +1,237 @@ +package de.danoeh.antennapod.activity; + +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.annotation.CallSuper; +import android.support.v4.view.MenuItemCompat; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; + +import com.google.android.gms.cast.ApplicationMetadata; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.core.cast.CastConsumer; +import de.danoeh.antennapod.core.cast.CastManager; +import de.danoeh.antennapod.core.cast.DefaultCastConsumer; +import de.danoeh.antennapod.core.cast.SwitchableMediaRouteActionProvider; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.playback.PlaybackService; + +/** + * Activity that allows for showing the MediaRouter button whenever there's a cast device in the + * network. + */ +public abstract class CastEnabledActivity extends AppCompatActivity + implements SharedPreferences.OnSharedPreferenceChangeListener { + public static final String TAG = "CastEnabledActivity"; + + protected CastManager castManager; + protected SwitchableMediaRouteActionProvider mediaRouteActionProvider; + private final CastButtonVisibilityManager castButtonVisibilityManager = new CastButtonVisibilityManager(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()). + registerOnSharedPreferenceChangeListener(this); + + castManager = CastManager.getInstance(); + castManager.addCastConsumer(castConsumer); + castButtonVisibilityManager.setPrefEnabled(UserPreferences.isCastEnabled()); + onCastConnectionChanged(castManager.isConnected()); + } + + @Override + protected void onDestroy() { + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) + .unregisterOnSharedPreferenceChangeListener(this); + castManager.removeCastConsumer(castConsumer); + super.onDestroy(); + } + + @Override + @CallSuper + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.cast_enabled, menu); + castButtonVisibilityManager.setMenu(menu); + return true; + } + + @Override + @CallSuper + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + mediaRouteActionProvider = castManager + .addMediaRouterButton(menu.findItem(R.id.media_route_menu_item)); + mediaRouteActionProvider.setEnabled(castButtonVisibilityManager.shouldEnable()); + return true; + } + + @Override + protected void onResume() { + super.onResume(); + castButtonVisibilityManager.setResumed(true); + } + + @Override + protected void onPause() { + super.onPause(); + castButtonVisibilityManager.setResumed(false); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (UserPreferences.PREF_CAST_ENABLED.equals(key)) { + boolean newValue = UserPreferences.isCastEnabled(); + Log.d(TAG, "onSharedPreferenceChanged(), isCastEnabled set to " + newValue); + castButtonVisibilityManager.setPrefEnabled(newValue); + // PlaybackService has its own listener, so if it's active we don't have to take action here. + if (!newValue && !PlaybackService.isRunning) { + CastManager.getInstance().disconnect(); + } + } + } + + CastConsumer castConsumer = new DefaultCastConsumer() { + @Override + public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { + onCastConnectionChanged(true); + } + + @Override + public void onDisconnected() { + onCastConnectionChanged(false); + } + }; + + private void onCastConnectionChanged(boolean connected) { + if (connected) { + castButtonVisibilityManager.onConnected(); + setVolumeControlStream(AudioManager.USE_DEFAULT_STREAM_TYPE); + } else { + castButtonVisibilityManager.onDisconnected(); + setVolumeControlStream(AudioManager.STREAM_MUSIC); + } + } + + /** + * Should be called by any activity or fragment for which the cast button should be shown. + * + * @param showAsAction refer to {@link MenuItem#setShowAsAction(int)} + */ + public final void requestCastButton(int showAsAction) { + castButtonVisibilityManager.requestCastButton(showAsAction); + } + + private class CastButtonVisibilityManager { + private volatile boolean prefEnabled = false; + private volatile boolean viewRequested = false; + private volatile boolean resumed = false; + private volatile boolean connected = false; + private volatile int showAsAction = MenuItem.SHOW_AS_ACTION_IF_ROOM; + private Menu menu; + + public synchronized void setPrefEnabled(boolean newValue) { + if (prefEnabled != newValue && resumed && (viewRequested || connected)) { + if (newValue) { + castManager.incrementUiCounter(); + } else { + castManager.decrementUiCounter(); + } + } + prefEnabled = newValue; + if (mediaRouteActionProvider != null) { + mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); + } + } + + public synchronized void setResumed(boolean newValue) { + if (resumed == newValue) { + Log.e(TAG, "resumed should never change to the same value"); + return; + } + resumed = newValue; + if (prefEnabled && (viewRequested || connected)) { + if (resumed) { + castManager.incrementUiCounter(); + } else { + castManager.decrementUiCounter(); + } + } + } + + public synchronized void setViewRequested(boolean newValue) { + if (viewRequested != newValue && resumed && prefEnabled && !connected) { + if (newValue) { + castManager.incrementUiCounter(); + } else { + castManager.decrementUiCounter(); + } + } + viewRequested = newValue; + if (mediaRouteActionProvider != null) { + mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); + } + } + + public synchronized void setConnected(boolean newValue) { + if (connected != newValue && resumed && prefEnabled && !prefEnabled) { + if (newValue) { + castManager.incrementUiCounter(); + } else { + castManager.decrementUiCounter(); + } + } + connected = newValue; + if (mediaRouteActionProvider != null) { + mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); + } + } + + public synchronized boolean shouldEnable() { + return prefEnabled && viewRequested; + } + + public void setMenu(Menu menu) { + setViewRequested(false); + showAsAction = MenuItem.SHOW_AS_ACTION_IF_ROOM; + this.menu = menu; + setShowAsAction(); + } + + public void requestCastButton(int showAsAction) { + setViewRequested(true); + this.showAsAction = showAsAction; + setShowAsAction(); + } + + public void onConnected() { + setConnected(true); + setShowAsAction(); + } + + public void onDisconnected() { + setConnected(false); + setShowAsAction(); + } + + private void setShowAsAction() { + if (menu == null) { + Log.d(TAG, "setShowAsAction() without a menu"); + return; + } + MenuItem item = menu.findItem(R.id.media_route_menu_item); + if (item == null) { + Log.e(TAG, "setShowAsAction(), but cast button not inflated"); + return; + } + MenuItemCompat.setShowAsAction(item, connected? MenuItem.SHOW_AS_ACTION_ALWAYS : showAsAction); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/CastplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/CastplayerActivity.java new file mode 100644 index 000000000..1ca4d095f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/CastplayerActivity.java @@ -0,0 +1,83 @@ +package de.danoeh.antennapod.activity; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import java.util.concurrent.atomic.AtomicBoolean; + +import de.danoeh.antennapod.core.service.playback.PlaybackService; + +/** + * Activity for controlling the remote playback on a Cast device. + */ +public class CastplayerActivity extends MediaplayerInfoActivity { + public static final String TAG = "CastPlayerActivity"; + + private AtomicBoolean isSetup = new AtomicBoolean(false); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!PlaybackService.isCasting()) { + Intent intent = PlaybackService.getPlayerActivityIntent(this); + if (!intent.getComponent().getClassName().equals(CastplayerActivity.class.getName())) { + finish(); + startActivity(intent); + } + } + } + + @Override + protected void onReloadNotification(int notificationCode) { + if (notificationCode == PlaybackService.EXTRA_CODE_AUDIO) { + Log.d(TAG, "ReloadNotification received, switching to Audioplayer now"); + saveCurrentFragment(); + finish(); + startActivity(new Intent(this, AudioplayerActivity.class)); + } else { + super.onReloadNotification(notificationCode); + } + } + + @Override + protected void setupGUI() { + if(isSetup.getAndSet(true)) { + return; + } + super.setupGUI(); + if (butPlaybackSpeed != null) { + butPlaybackSpeed.setVisibility(View.GONE); + } + if (butCastDisconnect != null) { + butCastDisconnect.setOnClickListener(v -> castManager.disconnect()); + butCastDisconnect.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onResume() { + if (!PlaybackService.isCasting()) { + Intent intent = PlaybackService.getPlayerActivityIntent(this); + if (!intent.getComponent().getClassName().equals(CastplayerActivity.class.getName())) { + saveCurrentFragment(); + finish(); + startActivity(intent); + } + } + super.onResume(); + } + + @Override + protected void onBufferStart() { + //sbPosition.setIndeterminate(true); + sbPosition.setEnabled(false); + } + + @Override + protected void onBufferEnd() { + //sbPosition.setIndeterminate(false); + sbPosition.setEnabled(true); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java index 8599eb4f4..b7c7d86c7 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -7,7 +7,6 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Configuration; import android.database.DataSetObserver; -import android.media.AudioManager; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -17,11 +16,11 @@ import android.support.v4.app.FragmentTransaction; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBarDrawerToggle; import android.support.v7.app.AlertDialog; -import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; import android.util.TypedValue; import android.view.ContextMenu; +import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; @@ -70,7 +69,7 @@ import rx.schedulers.Schedulers; /** * The activity that is shown when the user launches the app. */ -public class MainActivity extends AppCompatActivity implements NavDrawerActivity { +public class MainActivity extends CastEnabledActivity implements NavDrawerActivity { private static final String TAG = "MainActivity"; @@ -123,7 +122,6 @@ public class MainActivity extends AppCompatActivity implements NavDrawerActivity super.onCreate(savedInstanceState); StorageUtils.checkStorageAvailability(this); setContentView(R.layout.main); - setVolumeControlStream(AudioManager.STREAM_MUSIC); toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); @@ -507,6 +505,26 @@ public class MainActivity extends AppCompatActivity implements NavDrawerActivity } @Override + public boolean onCreateOptionsMenu(Menu menu) { + boolean retVal = super.onCreateOptionsMenu(menu); + switch (getLastNavFragment()) { + case QueueFragment.TAG: + case EpisodesFragment.TAG: + requestCastButton(MenuItem.SHOW_AS_ACTION_IF_ROOM); + return retVal; + case DownloadsFragment.TAG: + case PlaybackHistoryFragment.TAG: + case AddFeedFragment.TAG: + case SubscriptionFragment.TAG: + return retVal; + default: + requestCastButton(MenuItem.SHOW_AS_ACTION_NEVER); + return retVal; + } + + } + + @Override public boolean onOptionsItemSelected(MenuItem item) { if (drawerToggle.onOptionsItemSelected(item)) { return true; diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java index 4911f61bc..8ee91c7a3 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java @@ -6,13 +6,10 @@ import android.content.SharedPreferences; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.PixelFormat; -import android.media.AudioManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.support.v4.view.ViewCompat; import android.support.v7.app.AlertDialog; -import android.support.v7.app.AppCompatActivity; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; @@ -58,7 +55,7 @@ import rx.schedulers.Schedulers; * Provides general features which are both needed for playing audio and video * files. */ -public abstract class MediaplayerActivity extends AppCompatActivity implements OnSeekBarChangeListener { +public abstract class MediaplayerActivity extends CastEnabledActivity implements OnSeekBarChangeListener { private static final String TAG = "MediaplayerActivity"; private static final String PREFS = "MediaPlayerActivityPreferences"; private static final String PREF_SHOW_TIME_LEFT = "showTimeLeft"; @@ -68,7 +65,6 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O protected TextView txtvPosition; protected TextView txtvLength; protected SeekBar sbPosition; - protected Button butPlaybackSpeed; protected ImageButton butRev; protected TextView txtvRev; protected ImageButton butPlay; @@ -129,8 +125,8 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O } @Override - public void postStatusMsg(int msg) { - MediaplayerActivity.this.postStatusMsg(msg); + public void postStatusMsg(int msg, boolean showToast) { + MediaplayerActivity.this.postStatusMsg(msg, showToast); } @Override @@ -208,7 +204,6 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O Log.d(TAG, "onCreate()"); StorageUtils.checkStorageAvailability(this); - setVolumeControlStream(AudioManager.STREAM_MUSIC); orientation = getResources().getConfiguration().orientation; getWindow().setFormat(PixelFormat.TRANSPARENT); @@ -286,6 +281,7 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O @Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); + requestCastButton(MenuItem.SHOW_AS_ACTION_ALWAYS); MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.mediaplayer, menu); return true; @@ -589,7 +585,7 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O */ protected abstract void onAwaitingVideoSurface(); - protected abstract void postStatusMsg(int resId); + protected abstract void postStatusMsg(int resId, boolean showToast); protected abstract void clearStatusMsg(); @@ -644,40 +640,12 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O } } - private void updatePlaybackSpeedButton() { - if(butPlaybackSpeed == null) { - return; - } - if (controller == null) { - butPlaybackSpeed.setVisibility(View.GONE); - return; - } - updatePlaybackSpeedButtonText(); - ViewCompat.setAlpha(butPlaybackSpeed, controller.canSetPlaybackSpeed() ? 1.0f : 0.5f); - butPlaybackSpeed.setVisibility(View.VISIBLE); + protected void updatePlaybackSpeedButton() { + // Only meaningful on AudioplayerActivity, where it is overridden. } - private void updatePlaybackSpeedButtonText() { - if(butPlaybackSpeed == null) { - return; - } - if (controller == null) { - butPlaybackSpeed.setVisibility(View.GONE); - return; - } - float speed = 1.0f; - if(controller.canSetPlaybackSpeed()) { - try { - // we can only retrieve the playback speed from the controller/playback service - // once mediaplayer has been initialized - speed = Float.parseFloat(UserPreferences.getPlaybackSpeed()); - } catch (NumberFormatException e) { - Log.e(TAG, Log.getStackTraceString(e)); - UserPreferences.setPlaybackSpeed(String.valueOf(speed)); - } - } - String speedStr = String.format("%.2fx", speed); - butPlaybackSpeed.setText(speedStr); + protected void updatePlaybackSpeedButtonText() { + // Only meaningful on AudioplayerActivity, where it is overridden. } @@ -690,28 +658,29 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O showTimeLeft = prefs.getBoolean(PREF_SHOW_TIME_LEFT, false); Log.d("timeleft", showTimeLeft ? "true" : "false"); txtvLength = (TextView) findViewById(R.id.txtvLength); - txtvLength.setOnClickListener(v -> { - showTimeLeft = !showTimeLeft; - Playable media = controller.getMedia(); - if (media == null) { - return; - } + if (txtvLength != null) { + txtvLength.setOnClickListener(v -> { + showTimeLeft = !showTimeLeft; + Playable media = controller.getMedia(); + if (media == null) { + return; + } - String length; - if (showTimeLeft) { - length = "-" + Converter.getDurationStringLong(media.getDuration() - media.getPosition()); - } else { - length = Converter.getDurationStringLong(media.getDuration()); - } - txtvLength.setText(length); + String length; + if (showTimeLeft) { + length = "-" + Converter.getDurationStringLong(media.getDuration() - media.getPosition()); + } else { + length = Converter.getDurationStringLong(media.getDuration()); + } + txtvLength.setText(length); - SharedPreferences.Editor editor = prefs.edit(); - editor.putBoolean(PREF_SHOW_TIME_LEFT, showTimeLeft); - editor.apply(); - Log.d("timeleft on click", showTimeLeft ? "true" : "false"); - }); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PREF_SHOW_TIME_LEFT, showTimeLeft); + editor.apply(); + Log.d("timeleft on click", showTimeLeft ? "true" : "false"); + }); + } - butPlaybackSpeed = (Button) findViewById(R.id.butPlaybackSpeed); butRev = (ImageButton) findViewById(R.id.butRev); txtvRev = (TextView) findViewById(R.id.txtvRev); if (txtvRev != null) { @@ -731,47 +700,6 @@ public abstract class MediaplayerActivity extends AppCompatActivity implements O // BUTTON SETUP - if(butPlaybackSpeed != null) { - butPlaybackSpeed.setOnClickListener(v -> { - if (controller == null) { - return; - } - if (controller.canSetPlaybackSpeed()) { - String[] availableSpeeds = UserPreferences.getPlaybackSpeedArray(); - String currentSpeed = UserPreferences.getPlaybackSpeed(); - - // Provide initial value in case the speed list has changed - // out from under us - // and our current speed isn't in the new list - String newSpeed; - if (availableSpeeds.length > 0) { - newSpeed = availableSpeeds[0]; - } else { - newSpeed = "1.00"; - } - - for (int i = 0; i < availableSpeeds.length; i++) { - if (availableSpeeds[i].equals(currentSpeed)) { - if (i == availableSpeeds.length - 1) { - newSpeed = availableSpeeds[0]; - } else { - newSpeed = availableSpeeds[i + 1]; - } - break; - } - } - UserPreferences.setPlaybackSpeed(newSpeed); - controller.setPlaybackSpeed(Float.parseFloat(newSpeed)); - } else { - VariableSpeedDialog.showGetPluginDialog(this); - } - }); - butPlaybackSpeed.setOnLongClickListener(v -> { - VariableSpeedDialog.showDialog(this); - return true; - }); - } - if (butRev != null) { butRev.setOnClickListener(v -> onRewind()); butRev.setOnLongClickListener(new View.OnLongClickListener() { diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerInfoActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerInfoActivity.java new file mode 100644 index 000000000..e966b8cce --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerInfoActivity.java @@ -0,0 +1,620 @@ +package de.danoeh.antennapod.activity; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.design.widget.AppBarLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentStatePagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.widget.DrawerLayout; +import android.support.v7.app.ActionBarDrawerToggle; +import android.support.v7.widget.Toolbar; +import android.util.Log; +import android.util.TypedValue; +import android.view.ContextMenu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ListView; +import android.widget.Toast; + +import com.viewpagerindicator.CirclePageIndicator; + +import java.util.List; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.ChaptersListAdapter; +import de.danoeh.antennapod.adapter.NavListAdapter; +import de.danoeh.antennapod.core.asynctask.FeedRemover; +import de.danoeh.antennapod.core.dialog.ConfirmationDialog; +import de.danoeh.antennapod.core.feed.EventDistributor; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlaybackController; +import de.danoeh.antennapod.fragment.AddFeedFragment; +import de.danoeh.antennapod.fragment.ChaptersFragment; +import de.danoeh.antennapod.fragment.CoverFragment; +import de.danoeh.antennapod.fragment.DownloadsFragment; +import de.danoeh.antennapod.fragment.EpisodesFragment; +import de.danoeh.antennapod.fragment.ItemDescriptionFragment; +import de.danoeh.antennapod.fragment.PlaybackHistoryFragment; +import de.danoeh.antennapod.fragment.QueueFragment; +import de.danoeh.antennapod.fragment.SubscriptionFragment; +import de.danoeh.antennapod.menuhandler.NavDrawerActivity; +import de.danoeh.antennapod.preferences.PreferenceController; +import rx.Observable; +import rx.Subscription; +import rx.android.schedulers.AndroidSchedulers; +import rx.schedulers.Schedulers; + +/** + * Activity for playing files that do not require a video surface. + */ +public abstract class MediaplayerInfoActivity extends MediaplayerActivity implements NavDrawerActivity { + + private static final int POS_COVER = 0; + private static final int POS_DESCR = 1; + private static final int POS_CHAPTERS = 2; + private static final int NUM_CONTENT_FRAGMENTS = 3; + + final String TAG = "MediaplayerInfoActivity"; + private static final String PREFS = "AudioPlayerActivityPreferences"; + private static final String PREF_KEY_SELECTED_FRAGMENT_POSITION = "selectedFragmentPosition"; + + public static final String[] NAV_DRAWER_TAGS = { + QueueFragment.TAG, + EpisodesFragment.TAG, + SubscriptionFragment.TAG, + DownloadsFragment.TAG, + PlaybackHistoryFragment.TAG, + AddFeedFragment.TAG, + NavListAdapter.SUBSCRIPTION_LIST_TAG + }; + + protected Button butPlaybackSpeed; + protected ImageButton butCastDisconnect; + private DrawerLayout drawerLayout; + private NavListAdapter navAdapter; + private ListView navList; + private View navDrawer; + private ActionBarDrawerToggle drawerToggle; + private int mPosition = -1; + + private Playable media; + private ViewPager pager; + private MediaplayerInfoPagerAdapter pagerAdapter; + + private Subscription subscription; + + @Override + protected void onStop() { + super.onStop(); + Log.d(TAG, "onStop()"); + if(pagerAdapter != null) { + pagerAdapter.setController(null); + } + if(subscription != null) { + subscription.unsubscribe(); + } + EventDistributor.getInstance().unregister(contentUpdate); + saveCurrentFragment(); + } + + @Override + public void onDestroy() { + Log.d(TAG, "onDestroy()"); + super.onDestroy(); + // don't risk creating memory leaks + drawerLayout = null; + navAdapter = null; + navList = null; + navDrawer = null; + drawerToggle = null; + pager = null; + pagerAdapter = null; + } + + @Override + protected void chooseTheme() { + setTheme(UserPreferences.getNoTitleTheme()); + } + + protected void saveCurrentFragment() { + if(pager == null) { + return; + } + Log.d(TAG, "Saving preferences"); + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + prefs.edit() + .putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, pager.getCurrentItem()) + .commit(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if(drawerToggle != null) { + drawerToggle.onConfigurationChanged(newConfig); + } + } + + private void loadLastFragment() { + Log.d(TAG, "Restoring instance state"); + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + int lastPosition = prefs.getInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, -1); + pager.setCurrentItem(lastPosition); + } + + @Override + protected void onResume() { + super.onResume(); + if(pagerAdapter != null && controller != null && controller.getMedia() != media) { + media = controller.getMedia(); + pagerAdapter.onMediaChanged(media); + pagerAdapter.setController(controller); + } + + EventDistributor.getInstance().register(contentUpdate); + loadData(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + protected void onAwaitingVideoSurface() { + Log.d(TAG, "onAwaitingVideoSurface was called in audio player -> switching to video player"); + startActivity(new Intent(this, VideoplayerActivity.class)); + } + + @Override + protected void postStatusMsg(int resId, boolean showToast) { + if (resId == R.string.player_preparing_msg + || resId == R.string.player_seeking_msg + || resId == R.string.player_buffering_msg) { + // TODO Show progress bar here + } + if (showToast) { + Toast.makeText(this, resId, Toast.LENGTH_SHORT).show(); + } + } + + @Override + protected void clearStatusMsg() { + // TODO Hide progress bar here + } + + + @Override + protected void setupGUI() { + super.setupGUI(); + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(""); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + findViewById(R.id.shadow).setVisibility(View.GONE); + AppBarLayout appBarLayout = (AppBarLayout) findViewById(R.id.appBar); + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()); + appBarLayout.setElevation(px); + } + drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); + navList = (ListView) findViewById(R.id.nav_list); + navDrawer = findViewById(R.id.nav_layout); + + drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, R.string.drawer_open, R.string.drawer_close); + drawerToggle.setDrawerIndicatorEnabled(false); + drawerLayout.setDrawerListener(drawerToggle); + + navAdapter = new NavListAdapter(itemAccess, this); + navList.setAdapter(navAdapter); + navList.setOnItemClickListener((parent, view, position, id) -> { + int viewType = parent.getAdapter().getItemViewType(position); + if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER) { + Intent intent = new Intent(MediaplayerInfoActivity.this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_NAV_TYPE, viewType); + intent.putExtra(MainActivity.EXTRA_NAV_INDEX, position); + startActivity(intent); + } + drawerLayout.closeDrawer(navDrawer); + }); + navList.setOnItemLongClickListener((parent, view, position, id) -> { + if (position < navAdapter.getTags().size()) { + showDrawerPreferencesDialog(); + return true; + } else { + mPosition = position; + return false; + } + }); + registerForContextMenu(navList); + drawerToggle.syncState(); + + findViewById(R.id.nav_settings).setOnClickListener(v -> { + drawerLayout.closeDrawer(navDrawer); + startActivity(new Intent(MediaplayerInfoActivity.this, PreferenceController.getPreferenceActivity())); + }); + + butPlaybackSpeed = (Button) findViewById(R.id.butPlaybackSpeed); + butCastDisconnect = (ImageButton) findViewById(R.id.butCastDisconnect); + + pager = (ViewPager) findViewById(R.id.pager); + pagerAdapter = new MediaplayerInfoPagerAdapter(getSupportFragmentManager(), media); + pagerAdapter.setController(controller); + pager.setAdapter(pagerAdapter); + CirclePageIndicator pageIndicator = (CirclePageIndicator) findViewById(R.id.page_indicator); + pageIndicator.setViewPager(pager); + loadLastFragment(); + pager.onSaveInstanceState(); + } + + @Override + protected void onPositionObserverUpdate() { + super.onPositionObserverUpdate(); + notifyMediaPositionChanged(); + } + + @Override + protected boolean loadMediaInfo() { + if (!super.loadMediaInfo()) { + return false; + } + if(controller.getMedia() != media) { + media = controller.getMedia(); + pagerAdapter.onMediaChanged(media); + } + return true; + } + + public void notifyMediaPositionChanged() { + if(pagerAdapter == null) { + return; + } + ChaptersFragment chaptersFragment = pagerAdapter.getChaptersFragment(); + if(chaptersFragment != null) { + ChaptersListAdapter adapter = (ChaptersListAdapter) chaptersFragment.getListAdapter(); + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + } + + @Override + protected void onReloadNotification(int notificationCode) { + if (notificationCode == PlaybackService.EXTRA_CODE_VIDEO) { + Log.d(TAG, "ReloadNotification received, switching to Videoplayer now"); + finish(); + startActivity(new Intent(this, VideoplayerActivity.class)); + + } + } + + @Override + protected void onBufferStart() { + postStatusMsg(R.string.player_buffering_msg, false); + } + + @Override + protected void onBufferEnd() { + clearStatusMsg(); + } + + public PlaybackController getPlaybackController() { + return controller; + } + + @Override + public boolean isDrawerOpen() { + return drawerLayout != null && navDrawer != null && drawerLayout.isDrawerOpen(navDrawer); + } + + @Override + protected int getContentViewResourceId() { + return R.layout.mediaplayerinfo_activity; + } + + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return drawerToggle != null && drawerToggle.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + if(v.getId() != R.id.nav_list) { + return; + } + AdapterView.AdapterContextMenuInfo adapterInfo = (AdapterView.AdapterContextMenuInfo) menuInfo; + int position = adapterInfo.position; + if(position < navAdapter.getSubscriptionOffset()) { + return; + } + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.nav_feed_context, menu); + Feed feed = navDrawerData.feeds.get(position - navAdapter.getSubscriptionOffset()); + menu.setHeaderTitle(feed.getTitle()); + // episodes are not loaded, so we cannot check if the podcast has new or unplayed ones! + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + final int position = mPosition; + mPosition = -1; // reset + if(position < 0) { + return false; + } + Feed feed = navDrawerData.feeds.get(position - navAdapter.getSubscriptionOffset()); + switch(item.getItemId()) { + case R.id.mark_all_seen_item: + DBWriter.markFeedSeen(feed.getId()); + return true; + case R.id.mark_all_read_item: + DBWriter.markFeedRead(feed.getId()); + return true; + case R.id.remove_item: + final FeedRemover remover = new FeedRemover(this, feed) { + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + } + }; + ConfirmationDialog conDialog = new ConfirmationDialog(this, + R.string.remove_feed_label, + R.string.feed_delete_confirmation_msg) { + @Override + public void onConfirmButtonPressed( + DialogInterface dialog) { + dialog.dismiss(); + if (controller != null) { + Playable playable = controller.getMedia(); + if (playable != null && playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; + if (media.getItem().getFeed().getId() == feed.getId()) { + Log.d(TAG, "Currently playing episode is about to be deleted, skipping"); + remover.skipOnCompletion = true; + if(controller.getStatus() == PlayerStatus.PLAYING) { + sendBroadcast(new Intent( + PlaybackService.ACTION_PAUSE_PLAY_CURRENT_EPISODE)); + } + } + } + } + remover.executeAsync(); + } + }; + conDialog.createNewDialog().show(); + return true; + default: + return super.onContextItemSelected(item); + } + } + + @Override + public void onBackPressed() { + if(isDrawerOpen()) { + drawerLayout.closeDrawer(navDrawer); + } else if (pager == null || pager.getCurrentItem() == 0) { + // If the user is currently looking at the first step, allow the system to handle the + // Back button. This calls finish() on this activity and pops the back stack. + super.onBackPressed(); + } else { + // Otherwise, select the previous step. + pager.setCurrentItem(pager.getCurrentItem() - 1); + } + } + + public void showDrawerPreferencesDialog() { + final List<String> hiddenDrawerItems = UserPreferences.getHiddenDrawerItems(); + String[] navLabels = new String[NAV_DRAWER_TAGS.length]; + final boolean[] checked = new boolean[NAV_DRAWER_TAGS.length]; + for (int i = 0; i < NAV_DRAWER_TAGS.length; i++) { + String tag = NAV_DRAWER_TAGS[i]; + navLabels[i] = navAdapter.getLabel(tag); + if (!hiddenDrawerItems.contains(tag)) { + checked[i] = true; + } + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.drawer_preferences); + builder.setMultiChoiceItems(navLabels, checked, (dialog, which, isChecked) -> { + if (isChecked) { + hiddenDrawerItems.remove(NAV_DRAWER_TAGS[which]); + } else { + hiddenDrawerItems.add(NAV_DRAWER_TAGS[which]); + } + }); + builder.setPositiveButton(R.string.confirm_label, (dialog, which) -> { + UserPreferences.setHiddenDrawerItems(hiddenDrawerItems); + }); + builder.setNegativeButton(R.string.cancel_label, null); + builder.create().show(); + } + + private DBReader.NavDrawerData navDrawerData; + + private void loadData() { + subscription = Observable.fromCallable(DBReader::getNavDrawerData) + .subscribeOn(Schedulers.newThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + navDrawerData = result; + if (navAdapter != null) { + navAdapter.notifyDataSetChanged(); + } + }, error -> { + Log.e(TAG, Log.getStackTraceString(error)); + }); + } + + + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((EventDistributor.FEED_LIST_UPDATE & arg) != 0) { + Log.d(TAG, "Received contentUpdate Intent."); + loadData(); + } + } + }; + + private final NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() { + @Override + public int getCount() { + if (navDrawerData != null) { + return navDrawerData.feeds.size(); + } else { + return 0; + } + } + + @Override + public Feed getItem(int position) { + if (navDrawerData != null && 0 <= position && position < navDrawerData.feeds.size()) { + return navDrawerData.feeds.get(position); + } else { + return null; + } + } + + @Override + public int getSelectedItemIndex() { + return -1; + } + + @Override + public int getQueueSize() { + return (navDrawerData != null) ? navDrawerData.queueSize : 0; + } + + @Override + public int getNumberOfNewItems() { + return (navDrawerData != null) ? navDrawerData.numNewItems : 0; + } + + @Override + public int getNumberOfDownloadedItems() { + return (navDrawerData != null) ? navDrawerData.numDownloadedItems : 0; + } + + @Override + public int getReclaimableItems() { + return (navDrawerData != null) ? navDrawerData.reclaimableSpace : 0; + } + + @Override + public int getFeedCounter(long feedId) { + return navDrawerData != null ? navDrawerData.feedCounters.get(feedId) : 0; + } + + @Override + public int getFeedCounterSum() { + if(navDrawerData == null) { + return 0; + } + int sum = 0; + for(int counter : navDrawerData.feedCounters.values()) { + sum += counter; + } + return sum; + } + }; + + public interface MediaplayerInfoContentFragment { + void onMediaChanged(Playable media); + } + + private static class MediaplayerInfoPagerAdapter extends FragmentStatePagerAdapter { + + private static final String TAG = "MPInfoPagerAdapter"; + + private Playable media; + private PlaybackController controller; + + public MediaplayerInfoPagerAdapter(FragmentManager fm, Playable media) { + super(fm); + this.media = media; + } + + private CoverFragment coverFragment; + private ItemDescriptionFragment itemDescriptionFragment; + private ChaptersFragment chaptersFragment; + + public void onMediaChanged(Playable media) { + Log.d(TAG, "media changing to " + media.getEpisodeTitle()); + this.media = media; + if(coverFragment != null) { + coverFragment.onMediaChanged(media); + } + if(itemDescriptionFragment != null) { + itemDescriptionFragment.onMediaChanged(media); + } + if(chaptersFragment != null) { + chaptersFragment.onMediaChanged(media); + } + } + + public void setController(PlaybackController controller) { + this.controller = controller; + if(chaptersFragment != null) { + chaptersFragment.setController(controller); + } + } + + @Nullable + public ChaptersFragment getChaptersFragment() { + return chaptersFragment; + } + + @Override + public Fragment getItem(int position) { + Log.d(TAG, "getItem(" + position + ")"); + switch (position) { + case POS_COVER: + if(coverFragment == null) { + coverFragment = CoverFragment.newInstance(media); + } + return coverFragment; + case POS_DESCR: + if(itemDescriptionFragment == null) { + itemDescriptionFragment = ItemDescriptionFragment.newInstance(media, true, true); + } + return itemDescriptionFragment; + case POS_CHAPTERS: + if(chaptersFragment == null) { + chaptersFragment = ChaptersFragment.newInstance(media); + chaptersFragment.setController(controller); + } + return chaptersFragment; + default: + return null; + } + } + + @Override + public int getCount() { + return NUM_CONTENT_FRAGMENTS; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java index 42c9edd99..a52382dea 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java @@ -83,6 +83,12 @@ public class VideoplayerActivity extends MediaplayerActivity { launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, true); startService(launchIntent); + } else if (PlaybackService.isCasting()) { + Intent intent = PlaybackService.getPlayerActivityIntent(this); + if (!intent.getComponent().getClassName().equals(VideoplayerActivity.class.getName())) { + finish(); + startActivity(intent); + } } } @@ -159,7 +165,7 @@ public class VideoplayerActivity extends MediaplayerActivity { } @Override - protected void postStatusMsg(int resId) { + protected void postStatusMsg(int resId, boolean showToast) { if (resId == R.string.player_preparing_msg) { progressIndicator.setVisibility(View.VISIBLE); } else { @@ -257,6 +263,10 @@ public class VideoplayerActivity extends MediaplayerActivity { Log.d(TAG, "ReloadNotification received, switching to Audioplayer now"); finish(); startActivity(new Intent(this, AudioplayerActivity.class)); + } else if (notificationCode == PlaybackService.EXTRA_CODE_CAST) { + Log.d(TAG, "ReloadNotification received, switching to Castplayer now"); + finish(); + startActivity(new Intent(this, CastplayerActivity.class)); } } diff --git a/app/src/main/java/de/danoeh/antennapod/config/PlaybackServiceCallbacksImpl.java b/app/src/main/java/de/danoeh/antennapod/config/PlaybackServiceCallbacksImpl.java index 997befe99..1ab60ef61 100644 --- a/app/src/main/java/de/danoeh/antennapod/config/PlaybackServiceCallbacksImpl.java +++ b/app/src/main/java/de/danoeh/antennapod/config/PlaybackServiceCallbacksImpl.java @@ -5,6 +5,7 @@ import android.content.Intent; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.AudioplayerActivity; +import de.danoeh.antennapod.activity.CastplayerActivity; import de.danoeh.antennapod.activity.VideoplayerActivity; import de.danoeh.antennapod.core.PlaybackServiceCallbacks; import de.danoeh.antennapod.core.feed.MediaType; @@ -12,7 +13,10 @@ import de.danoeh.antennapod.core.feed.MediaType; public class PlaybackServiceCallbacksImpl implements PlaybackServiceCallbacks { @Override - public Intent getPlayerActivityIntent(Context context, MediaType mediaType) { + public Intent getPlayerActivityIntent(Context context, MediaType mediaType, boolean remotePlayback) { + if (remotePlayback) { + return new Intent(context, CastplayerActivity.class); + } if (mediaType == MediaType.VIDEO) { return new Intent(context, VideoplayerActivity.class); } else { diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java index aea911f79..77e66f3b0 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java @@ -7,14 +7,14 @@ import android.view.View; import android.widget.ListView; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.AudioplayerActivity.AudioplayerContentFragment; +import de.danoeh.antennapod.activity.MediaplayerInfoActivity.MediaplayerInfoContentFragment; import de.danoeh.antennapod.adapter.ChaptersListAdapter; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.util.playback.Playable; import de.danoeh.antennapod.core.util.playback.PlaybackController; -public class ChaptersFragment extends ListFragment implements AudioplayerContentFragment { +public class ChaptersFragment extends ListFragment implements MediaplayerInfoContentFragment { private static final String TAG = "ChaptersFragment"; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java index d3b97f9df..943ddeec7 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java @@ -12,14 +12,14 @@ import android.widget.TextView; import com.bumptech.glide.Glide; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.AudioplayerActivity.AudioplayerContentFragment; +import de.danoeh.antennapod.activity.MediaplayerInfoActivity.MediaplayerInfoContentFragment; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.util.playback.Playable; /** * Displays the cover and the title of a FeedItem. */ -public class CoverFragment extends Fragment implements AudioplayerContentFragment { +public class CoverFragment extends Fragment implements MediaplayerInfoContentFragment { private static final String TAG = "CoverFragment"; private static final String ARG_PLAYABLE = "arg.playable"; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java index ca60e7bf2..758f8095d 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java @@ -172,7 +172,7 @@ public class ExternalPlayerFragment extends Fragment { .into(imgvCover); fragmentLayout.setVisibility(View.VISIBLE); - if (controller.isPlayingVideo()) { + if (controller.isPlayingVideoLocally()) { butPlay.setVisibility(View.GONE); } else { butPlay.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java index 5b301333e..55d28cadb 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java @@ -27,8 +27,8 @@ import android.webkit.WebViewClient; import android.widget.Toast; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.AudioplayerActivity; -import de.danoeh.antennapod.activity.AudioplayerActivity.AudioplayerContentFragment; +import de.danoeh.antennapod.activity.MediaplayerInfoActivity; +import de.danoeh.antennapod.activity.MediaplayerInfoActivity.MediaplayerInfoContentFragment; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.DBReader; @@ -47,7 +47,7 @@ import rx.schedulers.Schedulers; /** * Displays the description of a Playable object in a Webview. */ -public class ItemDescriptionFragment extends Fragment implements AudioplayerContentFragment { +public class ItemDescriptionFragment extends Fragment implements MediaplayerInfoContentFragment { private static final String TAG = "ItemDescriptionFragment"; @@ -371,8 +371,8 @@ public class ItemDescriptionFragment extends Fragment implements AudioplayerCont private void onTimecodeLinkSelected(String link) { int time = Timeline.getTimecodeLinkTime(link); - if (getActivity() != null && getActivity() instanceof AudioplayerActivity) { - PlaybackController pc = ((AudioplayerActivity) getActivity()).getPlaybackController(); + if (getActivity() != null && getActivity() instanceof MediaplayerInfoActivity) { + PlaybackController pc = ((MediaplayerInfoActivity) getActivity()).getPlaybackController(); if (pc != null) { pc.seekTo(time); } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java index 9740c9af9..e721af47d 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java @@ -39,6 +39,7 @@ import org.apache.commons.lang3.ArrayUtils; import java.util.List; import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.CastEnabledActivity; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; import de.danoeh.antennapod.core.event.DownloadEvent; @@ -311,6 +312,7 @@ public class ItemFragment extends Fragment implements OnSwipeGesture { if(!isAdded() || item == null) { return; } + ((CastEnabledActivity) getActivity()).requestCastButton(MenuItem.SHOW_AS_ACTION_ALWAYS); inflater.inflate(R.menu.feeditem_options, menu); popupMenu = menu; if (item.hasMedia()) { diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceController.java b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceController.java index 3c1332e33..6c03dc7ae 100644 --- a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceController.java +++ b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceController.java @@ -34,6 +34,8 @@ import android.widget.Toast; import android.widget.ListView; import com.afollestad.materialdialogs.MaterialDialog; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; import org.apache.commons.lang3.ArrayUtils; @@ -410,6 +412,22 @@ public class PreferenceController implements SharedPreferences.OnSharedPreferenc ui.getActivity().startActivity(Intent.createChooser(emailIntent, intentTitle)); return true; }); + //checks whether Google Play Services is installed on the device (condition necessary for Cast support) + ui.findPreference(UserPreferences.PREF_CAST_ENABLED).setOnPreferenceChangeListener((preference, o) -> { + if (o instanceof Boolean && ((Boolean) o)) { + final int googlePlayServicesCheck = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(ui.getActivity()); + if (googlePlayServicesCheck == ConnectionResult.SUCCESS) { + return true; + } else { + GoogleApiAvailability.getInstance() + .getErrorDialog(ui.getActivity(), googlePlayServicesCheck, 0) + .show(); + return false; + } + } + return true; + }); buildEpisodeCleanupPreference(); buildSmartMarkAsPlayedPreference(); buildAutodownloadSelectedNetworsPreference(); diff --git a/app/src/main/res/layout/external_player_fragment.xml b/app/src/main/res/layout/external_player_fragment.xml index e6fd21241..fb7abde55 100644 --- a/app/src/main/res/layout/external_player_fragment.xml +++ b/app/src/main/res/layout/external_player_fragment.xml @@ -37,6 +37,18 @@ android:indeterminate="false" tools:progress="100"/> + <ImageButton + android:id="@+id/butPlay" + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_alignParentRight="true" + android:layout_alignParentEnd="true" + android:layout_below="@id/episodeProgress" + android:layout_centerVertical="true" + android:contentDescription="@string/pause_label" + android:background="?attr/selectableItemBackground" + tools:src="@drawable/ic_play_arrow_white_36dp"/> + <TextView android:id="@+id/txtvTitle" android:layout_width="wrap_content" @@ -72,18 +84,6 @@ android:maxLines="1" tools:text="Episode author that is too long and will cause the text to wrap"/> - <ImageButton - android:id="@+id/butPlay" - android:layout_width="52dp" - android:layout_height="52dp" - android:layout_alignParentRight="true" - android:layout_alignParentEnd="true" - android:layout_below="@id/episodeProgress" - android:layout_centerVertical="true" - android:contentDescription="@string/pause_label" - android:background="?attr/selectableItemBackground" - tools:src="@drawable/ic_play_arrow_white_36dp"/> - </RelativeLayout> </LinearLayout> diff --git a/app/src/main/res/layout/audioplayer_activity.xml b/app/src/main/res/layout/mediaplayerinfo_activity.xml index fb4f995a2..0f68b503e 100644 --- a/app/src/main/res/layout/audioplayer_activity.xml +++ b/app/src/main/res/layout/mediaplayerinfo_activity.xml @@ -152,6 +152,21 @@ android:src="?attr/av_fast_forward" android:textSize="@dimen/text_size_medium" android:textAllCaps="false" + tools:visibility="gone" + tools:background="@android:color/holo_green_dark" /> + + <ImageButton + android:id="@+id/butCastDisconnect" + android:layout_width="@dimen/audioplayer_playercontrols_length" + android:layout_height="@dimen/audioplayer_playercontrols_length" + android:layout_toLeftOf="@id/butRev" + android:background="?attr/selectableItemBackground" + android:contentDescription="@string/cast_disconnect_label" + android:src="?attr/ic_cast_disconnect" + android:scaleType="fitCenter" + android:visibility="gone" + tools:visibility="visible" + tools:src="@drawable/ic_cast_disconnect_white_36dp" tools:background="@android:color/holo_green_dark" /> <ImageButton diff --git a/app/src/main/res/menu/cast_enabled.xml b/app/src/main/res/menu/cast_enabled.xml new file mode 100644 index 000000000..d6e85c311 --- /dev/null +++ b/app/src/main/res/menu/cast_enabled.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:custom="http://schemas.android.com/apk/res-auto"> + + <item + android:id="@+id/media_route_menu_item" + android:title="@string/cast_media_route_menu_title" + custom:actionProviderClass="de.danoeh.antennapod.core.cast.SwitchableMediaRouteActionProvider" + custom:showAsAction="ifRoom"/> +</menu>
\ No newline at end of file diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index ecdcd3517..57829e3e1 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -293,5 +293,14 @@ android:key="prefAbout" android:title="@string/about_pref"/> </PreferenceCategory> + + <PreferenceCategory android:title="@string/experimental_pref"> + <de.danoeh.antennapod.preferences.SwitchCompatPreference + android:defaultValue="false" + android:enabled="true" + android:key="prefCast" + android:summary="@string/pref_cast_message" + android:title="@string/pref_cast_title"/> + </PreferenceCategory> </PreferenceScreen> diff --git a/build.gradle b/build.gradle index f692e201a..3eace8f8a 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,9 @@ project.ext { triangleLabelViewVersion = "1.1.0" audioPlayerVersion = "v1.0.16" + + castCompanionLibVer = "2.8.3" + playServicesVersion = "8.4.0" } task wrapper(type: Wrapper) { diff --git a/core/build.gradle b/core/build.gradle index f79ea8fc8..5264e435b 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -57,4 +57,9 @@ dependencies { compile "io.reactivex:rxandroid:$rxAndroidVersion" compile "com.github.AntennaPod:AntennaPod-AudioPlayer:$audioPlayerVersion" + + // Add casting features + compile "com.google.android.libraries.cast.companionlibrary:ccl:$castCompanionLibVer" + compile "com.android.support:mediarouter-v7:$supportVersion" + compile "com.google.android.gms:play-services-cast:$playServicesVersion" } diff --git a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java index a96affb23..9bbccbb82 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java +++ b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.core; import android.content.Context; +import de.danoeh.antennapod.core.cast.CastManager; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.PodDBAdapter; @@ -41,6 +42,7 @@ public class ClientConfig { UpdateManager.init(context); PlaybackPreferences.init(context); NetworkUtils.init(context); + CastManager.init(context); initialized = true; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java index 08ccb6d71..13a32ab8a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java +++ b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java @@ -15,9 +15,10 @@ public interface PlaybackServiceCallbacks { * type of media that is being played. * * @param mediaType The type of media that is being played. + * @param remotePlayback true if the media is played on a remote device. * @return A non-null activity intent. */ - Intent getPlayerActivityIntent(Context context, MediaType mediaType); + Intent getPlayerActivityIntent(Context context, MediaType mediaType, boolean remotePlayback); /** * Returns true if the PlaybackService should load new episodes from the queue when playback ends diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java b/core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java new file mode 100644 index 000000000..213dd1875 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java @@ -0,0 +1,11 @@ +package de.danoeh.antennapod.core.cast; + +import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer; + +public interface CastConsumer extends VideoCastConsumer{ + + /** + * Called when the stream's volume is changed. + */ + void onStreamVolumeChanged(double value, boolean isMute); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java b/core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java new file mode 100644 index 000000000..5b1fdab61 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java @@ -0,0 +1,1766 @@ +/* + * Copyright (C) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * ------------------------------------------------------------------------ + * + * Changes made by Domingos Lopes <domingos86lopes@gmail.com> + * + * original can be found at http://www.github.com/googlecast/CastCompanionLibrary-android + */ + +package de.danoeh.antennapod.core.cast; + +import android.content.Context; +import android.os.Build; +import android.support.v4.view.MenuItemCompat; +import android.support.v7.media.MediaRouter; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MenuItem; + +import com.google.android.gms.cast.ApplicationMetadata; +import com.google.android.gms.cast.Cast; +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.RemoteMediaPlayer; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager; +import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration; +import com.google.android.libraries.cast.companionlibrary.cast.MediaQueue; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.OnFailedListener; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; + +import org.json.JSONObject; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.TimeUnit; + +import de.danoeh.antennapod.core.R; + +import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_PLAY; +import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_UNCHANGED; + +/** + * A subclass of {@link BaseCastManager} that is suitable for casting video contents (it + * also provides a single custom data channel/namespace if an out-of-band communication is + * needed). + * <p> + * Clients need to initialize this class by calling + * {@link #init(android.content.Context)} in the Application's + * {@code onCreate()} method. To access the (singleton) instance of this class, clients + * need to call {@link #getInstance()}. + * <p>This + * class manages various states of the remote cast device. Client applications, however, can + * complement the default behavior of this class by hooking into various callbacks that it provides + * (see {@link CastConsumer}). + * Since the number of these callbacks is usually much larger than what a single application might + * be interested in, there is a no-op implementation of this interface (see + * {@link DefaultCastConsumer}) that applications can subclass to override only those methods that + * they are interested in. Since this library depends on the cast functionalities provided by the + * Google Play services, the library checks to ensure that the right version of that service is + * installed. It also provides a simple static method {@code checkGooglePlayServices()} that clients + * can call at an early stage of their applications to provide a dialog for users if they need to + * update/activate their Google Play Services library. + * + * @see CastConfiguration + */ +public class CastManager extends BaseCastManager implements OnFailedListener { + public static final String TAG = "CastManager"; + + public static final String CAST_APP_ID = CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID; + + public static final double DEFAULT_VOLUME_STEP = 0.05; + public static final long DEFAULT_LIVE_STREAM_DURATION_MS = TimeUnit.HOURS.toMillis(2); + private double volumeStep = DEFAULT_VOLUME_STEP; + private MediaQueue mediaQueue; + private MediaStatus mediaStatus; + + private static CastManager INSTANCE; + private RemoteMediaPlayer remoteMediaPlayer; + private int state = MediaStatus.PLAYER_STATE_IDLE; + private int idleReason; + private final Set<CastConsumer> castConsumers = new CopyOnWriteArraySet<>(); + private long liveStreamDuration = DEFAULT_LIVE_STREAM_DURATION_MS; + private MediaQueueItem preLoadingItem; + + public static final int QUEUE_OPERATION_LOAD = 1; + public static final int QUEUE_OPERATION_INSERT_ITEMS = 2; + public static final int QUEUE_OPERATION_UPDATE_ITEMS = 3; + public static final int QUEUE_OPERATION_JUMP = 4; + public static final int QUEUE_OPERATION_REMOVE_ITEM = 5; + public static final int QUEUE_OPERATION_REMOVE_ITEMS = 6; + public static final int QUEUE_OPERATION_REORDER = 7; + public static final int QUEUE_OPERATION_MOVE = 8; + public static final int QUEUE_OPERATION_APPEND = 9; + public static final int QUEUE_OPERATION_NEXT = 10; + public static final int QUEUE_OPERATION_PREV = 11; + public static final int QUEUE_OPERATION_SET_REPEAT = 12; + + private CastManager(Context context, CastConfiguration castConfiguration) { + super(context, castConfiguration); + Log.d(TAG, "CastManager is instantiated"); + } + + public static synchronized CastManager init(Context context) { + if (INSTANCE == null) { + //TODO also setup dialog factory if necessary + CastConfiguration castConfiguration = new CastConfiguration.Builder(CAST_APP_ID) + .enableDebug() + .enableAutoReconnect() + .enableWifiReconnection() + .setLaunchOptions(true, Locale.getDefault()) + .build(); + Log.d(TAG, "New instance of CastManager is created"); + if (ConnectionResult.SUCCESS != GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context)) { + Log.e(TAG, "Couldn't find the appropriate version of Google Play Services"); + //TODO check whether creating an instance without google play services installed actually gives an exception + } + INSTANCE = new CastManager(context, castConfiguration); + } + return INSTANCE; + } + + /** + * Returns a (singleton) instance of this class. Clients should call this method in order to + * get a hold of this singleton instance, only after it is initialized. If it is not initialized + * yet, an {@link IllegalStateException} will be thrown. + * + */ + public static CastManager getInstance() { + if (INSTANCE == null) { + String msg = "No CastManager instance was found, did you forget to initialize it?"; + Log.e(TAG, msg); + throw new IllegalStateException(msg); + } + return INSTANCE; + } + + /** + * Returns the active {@link RemoteMediaPlayer} instance. Since there are a number of media + * control APIs that this library do not provide a wrapper for, client applications can call + * those methods directly after obtaining an instance of the active {@link RemoteMediaPlayer}. + */ + public final RemoteMediaPlayer getRemoteMediaPlayer() { + return remoteMediaPlayer; + } + + /** + * Determines if the media that is loaded remotely is a live stream or not. + * + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public final boolean isRemoteStreamLive() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + MediaInfo info = getRemoteMediaInformation(); + return (info != null) && (info.getStreamType() == MediaInfo.STREAM_TYPE_LIVE); + } + + /* + * A simple check to make sure remoteMediaPlayer is not null + */ + private void checkRemoteMediaPlayerAvailable() throws NoConnectionException { + if (remoteMediaPlayer == null) { + throw new NoConnectionException(); + } + } + + /** + * Returns the url for the media that is currently playing on the remote device. If there is no + * connection, this will return <code>null</code>. + * + * @throws NoConnectionException If no connectivity to the device exists + * @throws TransientNetworkDisconnectionException If framework is still trying to recover from + * a possibly transient loss of network + */ + public String getRemoteMediaUrl() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + if (remoteMediaPlayer != null && remoteMediaPlayer.getMediaInfo() != null) { + MediaInfo info = remoteMediaPlayer.getMediaInfo(); + remoteMediaPlayer.getMediaStatus().getPlayerState(); + return info.getContentId(); + } + throw new NoConnectionException(); + } + + /** + * Indicates if the remote media is currently playing (or buffering). + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isRemoteMediaPlaying() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + return state == MediaStatus.PLAYER_STATE_BUFFERING + || state == MediaStatus.PLAYER_STATE_PLAYING; + } + + /** + * Returns <code>true</code> if the remote connected device is playing a movie. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isRemoteMediaPaused() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + return state == MediaStatus.PLAYER_STATE_PAUSED; + } + + /** + * Returns <code>true</code> only if there is a media on the remote being played, paused or + * buffered. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isRemoteMediaLoaded() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + return isRemoteMediaPaused() || isRemoteMediaPlaying(); + } + + /** + * Returns the {@link MediaInfo} for the current media + * + * @throws NoConnectionException If no connectivity to the device exists + * @throws TransientNetworkDisconnectionException If framework is still trying to recover from + * a possibly transient loss of network + */ + public MediaInfo getRemoteMediaInformation() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getMediaInfo(); + } + + /** + * Gets the remote's system volume. It internally detects what type of volume is used. + * + * @throws NoConnectionException If no connectivity to the device exists + * @throws TransientNetworkDisconnectionException If framework is still trying to recover from + * a possibly transient loss of network + */ + public double getStreamVolume() throws TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getMediaStatus().getStreamVolume(); + } + + /** + * Sets the stream volume. + * + * @param volume Should be a value between 0 and 1, inclusive. + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + * @throws CastException If setting system volume fails + */ + public void setStreamVolume(double volume) throws CastException, + TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + if (volume > 1.0) { + volume = 1.0; + } else if (volume < 0) { + volume = 0.0; + } + + RemoteMediaPlayer mediaPlayer = getRemoteMediaPlayer(); + if (mediaPlayer == null) { + throw new NoConnectionException(); + } + mediaPlayer.setStreamVolume(mApiClient, volume).setResultCallback( + (result) -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_setting_volume, + result.getStatus().getStatusCode()); + } else { + CastManager.this.onStreamVolumeChanged(); + } + }); + } + + /** + * Returns <code>true</code> if remote Stream is muted. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isStreamMute() throws TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getMediaStatus().isMute(); + } + + /** + * Returns <code>true</code> if remote device is muted. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isMute() throws TransientNetworkDisconnectionException, NoConnectionException { + return isStreamMute() || isDeviceMute(); + } + + /** + * Mutes or un-mutes the stream volume. + * + * @throws CastException + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void setStreamMute(boolean mute) throws CastException, TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + remoteMediaPlayer.setStreamMute(mApiClient, mute); + } + + /** + * Returns the duration of the media that is loaded, in milliseconds. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public long getMediaDuration() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getStreamDuration(); + } + + /** + * Returns the time left (in milliseconds) of the current media. If there is no + * {@code RemoteMediaPlayer}, it returns -1. + * + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public long getMediaTimeRemaining() + throws TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + if (remoteMediaPlayer == null) { + return -1; + } + return isRemoteStreamLive() ? liveStreamDuration : remoteMediaPlayer.getStreamDuration() + - remoteMediaPlayer.getApproximateStreamPosition(); + } + + /** + * Returns the current (approximate) position of the current media, in milliseconds. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public long getCurrentMediaPosition() throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getApproximateStreamPosition(); + } + + public int getApplicationStandbyState() throws IllegalStateException { + Log.d(TAG, "getApplicationStandbyState()"); + return Cast.CastApi.getStandbyState(mApiClient); + } + + private void onApplicationDisconnected(int errorCode) { + Log.d(TAG, "onApplicationDisconnected() reached with error code: " + errorCode); + mApplicationErrorCode = errorCode; + for (CastConsumer consumer : castConsumers) { + consumer.onApplicationDisconnected(errorCode); + } + if (mMediaRouter != null) { + Log.d(TAG, "onApplicationDisconnected(): Cached RouteInfo: " + getRouteInfo()); + Log.d(TAG, "onApplicationDisconnected(): Selected RouteInfo: " + + mMediaRouter.getSelectedRoute()); + if (getRouteInfo() == null || mMediaRouter.getSelectedRoute().equals(getRouteInfo())) { + Log.d(TAG, "onApplicationDisconnected(): Setting route to default"); + mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); + } + } + onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); + } + + private void onApplicationStatusChanged() { + if (!isConnected()) { + return; + } + try { + String appStatus = Cast.CastApi.getApplicationStatus(mApiClient); + Log.d(TAG, "onApplicationStatusChanged() reached: " + appStatus); + for (CastConsumer consumer : castConsumers) { + consumer.onApplicationStatusChanged(appStatus); + } + } catch (IllegalStateException e) { + Log.e(TAG, "onApplicationStatusChanged()", e); + } + } + + private void onDeviceVolumeChanged() { + Log.d(TAG, "onDeviceVolumeChanged() reached"); + double volume; + try { + volume = getDeviceVolume(); + boolean isMute = isDeviceMute(); + for (CastConsumer consumer : castConsumers) { + consumer.onVolumeChanged(volume, isMute); + } + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Failed to get volume", e); + } + + } + + private void onStreamVolumeChanged() { + Log.d(TAG, "onStreamVolumeChanged() reached"); + double volume; + try { + volume = getStreamVolume(); + boolean isMute = isStreamMute(); + for (CastConsumer consumer : castConsumers) { + consumer.onStreamVolumeChanged(volume, isMute); + } + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Failed to get volume", e); + } + } + + @Override + protected void onApplicationConnected(ApplicationMetadata appMetadata, + String applicationStatus, String sessionId, boolean wasLaunched) { + Log.d(TAG, "onApplicationConnected() reached with sessionId: " + sessionId + + ", and mReconnectionStatus=" + mReconnectionStatus); + mApplicationErrorCode = NO_APPLICATION_ERROR; + if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { + // we have tried to reconnect and successfully launched the app, so + // it is time to select the route and make the cast icon happy :-) + List<MediaRouter.RouteInfo> routes = mMediaRouter.getRoutes(); + if (routes != null) { + String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); + for (MediaRouter.RouteInfo routeInfo : routes) { + if (routeId.equals(routeInfo.getId())) { + // found the right route + Log.d(TAG, "Found the correct route during reconnection attempt"); + mReconnectionStatus = RECONNECTION_STATUS_FINALIZED; + mMediaRouter.selectRoute(routeInfo); + break; + } + } + } + } + try { + //attachDataChannel(); + attachMediaChannel(); + mSessionId = sessionId; + // saving device for future retrieval; we only save the last session info + mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, mSessionId); + remoteMediaPlayer.requestStatus(mApiClient).setResultCallback(result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_status_request, + result.getStatus().getStatusCode()); + } + }); + for (CastConsumer consumer : castConsumers) { + consumer.onApplicationConnected(appMetadata, mSessionId, wasLaunched); + } + } catch (TransientNetworkDisconnectionException e) { + Log.e(TAG, "Failed to attach media/data channel due to network issues", e); + onFailed(R.string.cast_failed_no_connection_trans, NO_STATUS_CODE); + } catch (NoConnectionException e) { + Log.e(TAG, "Failed to attach media/data channel due to network issues", e); + onFailed(R.string.cast_failed_no_connection, NO_STATUS_CODE); + } + } + + /* + * (non-Javadoc) + * @see com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager + * #onConnectivityRecovered() + */ + @Override + public void onConnectivityRecovered() { + reattachMediaChannel(); + //reattachDataChannel(); + super.onConnectivityRecovered(); + } + + /* + * (non-Javadoc) + * @see com.google.android.gms.cast.CastClient.Listener#onApplicationStopFailed (int) + */ + @Override + public void onApplicationStopFailed(int errorCode) { + for (CastConsumer consumer : castConsumers) { + consumer.onApplicationStopFailed(errorCode); + } + } + + @Override + public void onApplicationConnectionFailed(int errorCode) { + Log.d(TAG, "onApplicationConnectionFailed() reached with errorCode: " + errorCode); + mApplicationErrorCode = errorCode; + if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { + if (errorCode == CastStatusCodes.APPLICATION_NOT_RUNNING) { + // while trying to re-establish session, we found out that the app is not running + // so we need to disconnect + mReconnectionStatus = RECONNECTION_STATUS_INACTIVE; + onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); + } + } else { + for (CastConsumer consumer : castConsumers) { + consumer.onApplicationConnectionFailed(errorCode); + } + onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); + if (mMediaRouter != null) { + Log.d(TAG, "onApplicationConnectionFailed(): Setting route to default"); + mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); + } + } + } + + /** + * Loads a media. For this to succeed, you need to have successfully launched the application. + * + * @param media The media to be loaded + * @param autoPlay If <code>true</code>, playback starts after load + * @param position Where to start the playback (only used if autoPlay is <code>true</code>. + * Units is milliseconds. + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void loadMedia(MediaInfo media, boolean autoPlay, int position) + throws TransientNetworkDisconnectionException, NoConnectionException { + loadMedia(media, autoPlay, position, null); + } + + /** + * Loads a media. For this to succeed, you need to have successfully launched the application. + * + * @param media The media to be loaded + * @param autoPlay If <code>true</code>, playback starts after load + * @param position Where to start the playback (only used if autoPlay is <code>true</code>). + * Units is milliseconds. + * @param customData Optional {@link JSONObject} data to be passed to the cast device + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void loadMedia(MediaInfo media, boolean autoPlay, int position, JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + loadMedia(media, null, autoPlay, position, customData); + } + + /** + * Loads a media. For this to succeed, you need to have successfully launched the application. + * + * @param media The media to be loaded + * @param activeTracks An array containing the list of track IDs to be set active for this + * media upon a successful load + * @param autoPlay If <code>true</code>, playback starts after load + * @param position Where to start the playback (only used if autoPlay is <code>true</code>). + * Units is milliseconds. + * @param customData Optional {@link JSONObject} data to be passed to the cast device + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void loadMedia(MediaInfo media, final long[] activeTracks, boolean autoPlay, + int position, JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "loadMedia"); + checkConnectivity(); + if (media == null) { + return; + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to load a video with no active media session"); + throw new NoConnectionException(); + } + + Log.d(TAG, "remoteMediaPlayer.load() with media=" + media.getMetadata().getString(MediaMetadata.KEY_TITLE) + + ", position=" + position + ", autoplay=" + autoPlay); + remoteMediaPlayer.load(mApiClient, media, autoPlay, position, activeTracks, customData) + .setResultCallback(result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaLoadResult(result.getStatus().getStatusCode()); + } + }); + } + + /** + * Loads and optionally starts playback of a new queue of media items. + * + * @param items Array of items to load, in the order that they should be played. Must not be + * {@code null} or empty. + * @param startIndex The array index of the item in the {@code items} array that should be + * played first (i.e., it will become the currentItem).If {@code repeatMode} + * is {@link MediaStatus#REPEAT_MODE_REPEAT_OFF} playback will end when the + * last item in the array is played. + * <p> + * This may be useful for continuation scenarios where the user was already + * using the sender application and in the middle decides to cast. This lets + * the sender application avoid mapping between the local and remote queue + * positions and/or avoid issuing an extra request to update the queue. + * <p> + * This value must be less than the length of {@code items}. + * @param repeatMode The repeat playback mode for the queue. One of + * {@link MediaStatus#REPEAT_MODE_REPEAT_OFF}, + * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL}, + * {@link MediaStatus#REPEAT_MODE_REPEAT_SINGLE} and + * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE}. + * @param customData Custom application-specific data to pass along with the request, may be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueLoad(final MediaQueueItem[] items, final int startIndex, final int repeatMode, + final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueLoad"); + checkConnectivity(); + if (items == null || items.length == 0) { + return; + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to queue one or more videos with no active media session"); + throw new NoConnectionException(); + } + Log.d(TAG, "remoteMediaPlayer.queueLoad() with " + items.length + "items, starting at " + + startIndex); + remoteMediaPlayer + .queueLoad(mApiClient, items, startIndex, repeatMode, customData) + .setResultCallback(result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_LOAD, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Inserts a list of new media items into the queue. + * + * @param itemsToInsert List of items to insert into the queue, in the order that they should be + * played. The itemId field of the items should be unassigned or the + * request will fail with an INVALID_PARAMS error. Must not be {@code null} + * or empty. + * @param insertBeforeItemId ID of the item that will be located immediately after the inserted + * list. If the value is {@link MediaQueueItem#INVALID_ITEM_ID} or + * invalid, the inserted list will be appended to the end of the + * queue. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + * @throws IllegalArgumentException + */ + public void queueInsertItems(final MediaQueueItem[] itemsToInsert, final int insertBeforeItemId, + final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueInsertItems"); + checkConnectivity(); + if (itemsToInsert == null || itemsToInsert.length == 0) { + throw new IllegalArgumentException("items cannot be empty or null"); + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to insert into queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueInsertItems(mApiClient, itemsToInsert, insertBeforeItemId, customData) + .setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult( + QUEUE_OPERATION_INSERT_ITEMS, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Updates properties of a subset of the existing items in the media queue. + * + * @param itemsToUpdate List of queue items to be updated. The items will retain the existing + * order and will be fully replaced with the ones provided, including the + * media information. Any other items currently in the queue will remain + * unchanged. The tracks information can not change once the item is loaded + * (if the item is the currentItem). If any of the items does not exist it + * will be ignored. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueUpdateItems(final MediaQueueItem[] itemsToUpdate, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to update the queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueUpdateItems(mApiClient, itemsToUpdate, customData).setResultCallback( + result -> { + Log.d(TAG, "queueUpdateItems() " + result.getStatus() + result.getStatus() + .isSuccess()); + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_UPDATE_ITEMS, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Plays the item with {@code itemId} in the queue. + * <p> + * If {@code itemId} is not found in the queue, this method will report success without sending + * a request to the receiver. + * + * @param itemId The ID of the item to which to jump. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + * @throws IllegalArgumentException + */ + public void queueJumpToItem(int itemId, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException, + IllegalArgumentException { + checkConnectivity(); + if (itemId == MediaQueueItem.INVALID_ITEM_ID) { + throw new IllegalArgumentException("itemId is not valid"); + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to jump in a queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueJumpToItem(mApiClient, itemId, customData).setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_JUMP, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Removes a list of items from the queue. If the remaining queue is empty, the media session + * will be terminated. + * + * @param itemIdsToRemove The list of media item IDs to remove. Must not be {@code null} or + * empty. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + * @throws IllegalArgumentException + */ + public void queueRemoveItems(final int[] itemIdsToRemove, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException, + IllegalArgumentException { + Log.d(TAG, "queueRemoveItems"); + checkConnectivity(); + if (itemIdsToRemove == null || itemIdsToRemove.length == 0) { + throw new IllegalArgumentException("itemIds cannot be empty or null"); + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to remove items from queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueRemoveItems(mApiClient, itemIdsToRemove, customData).setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEMS, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Removes the item with {@code itemId} from the queue. + * <p> + * If {@code itemId} is not found in the queue, this method will silently return without sending + * a request to the receiver. A {@code itemId} may not be in the queue because it wasn't + * originally in the queue, or it was removed by another sender. + * + * @param itemId The ID of the item to be removed. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + * @throws IllegalArgumentException + */ + public void queueRemoveItem(final int itemId, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException, + IllegalArgumentException { + Log.d(TAG, "queueRemoveItem"); + checkConnectivity(); + if (itemId == MediaQueueItem.INVALID_ITEM_ID) { + throw new IllegalArgumentException("itemId is invalid"); + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to remove an item from queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueRemoveItem(mApiClient, itemId, customData).setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEM, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Reorder a list of media items in the queue. + * + * @param itemIdsToReorder The list of media item IDs to reorder, in the new order. Any other + * items currently in the queue will maintain their existing order. The + * list will be inserted just before the item specified by + * {@code insertBeforeItemId}, or at the end of the queue if + * {@code insertBeforeItemId} is {@link MediaQueueItem#INVALID_ITEM_ID}. + * <p> + * For example: + * <p> + * If insertBeforeItemId is not specified <br> + * Existing queue: "A","D","G","H","B","E" <br> + * itemIds: "D","H","B" <br> + * New Order: "A","G","E","D","H","B" <br> + * <p> + * If insertBeforeItemId is "A" <br> + * Existing queue: "A","D","G","H","B" <br> + * itemIds: "D","H","B" <br> + * New Order: "D","H","B","A","G","E" <br> + * <p> + * If insertBeforeItemId is "G" <br> + * Existing queue: "A","D","G","H","B" <br> + * itemIds: "D","H","B" <br> + * New Order: "A","D","H","B","G","E" <br> + * <p> + * If any of the items does not exist it will be ignored. + * Must not be {@code null} or empty. + * @param insertBeforeItemId ID of the item that will be located immediately after the reordered + * list. If set to {@link MediaQueueItem#INVALID_ITEM_ID}, the + * reordered list will be appended at the end of the queue. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueReorderItems(final int[] itemIdsToReorder, final int insertBeforeItemId, + final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException, + IllegalArgumentException { + Log.d(TAG, "queueReorderItems"); + checkConnectivity(); + if (itemIdsToReorder == null || itemIdsToReorder.length == 0) { + throw new IllegalArgumentException("itemIdsToReorder cannot be empty or null"); + } + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to reorder items in a queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueReorderItems(mApiClient, itemIdsToReorder, insertBeforeItemId, customData) + .setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REORDER, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Moves the item with {@code itemId} to a new position in the queue. + * <p> + * If {@code itemId} is not found in the queue, either because it wasn't there originally or it + * was removed by another sender before calling this function, this function will silently + * return without sending a request to the receiver. + * + * @param itemId The ID of the item to be moved. + * @param newIndex The new index of the item. If the value is negative, an error will be + * returned. If the value is out of bounds, or becomes out of bounds because the + * queue was shortened by another sender while this request is in progress, the + * item will be moved to the end of the queue. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueMoveItemToNewIndex(int itemId, int newIndex, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueMoveItemToNewIndex"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to mote item to new index with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueMoveItemToNewIndex(mApiClient, itemId, newIndex, customData) + .setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_MOVE, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Appends a new media item to the end of the queue. + * + * @param item The item to append. Must not be {@code null}. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueAppendItem(MediaQueueItem item, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueAppendItem"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to append item with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueAppendItem(mApiClient, item, customData) + .setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_APPEND, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Jumps to the next item in the queue. + * + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueNext(final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueNext"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to update the queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueNext(mApiClient, customData).setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_NEXT, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Jumps to the previous item in the queue. + * + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queuePrev(final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queuePrev"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to update the queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queuePrev(mApiClient, customData).setResultCallback( + result -> { + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_PREV, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Inserts an item in the queue and starts the playback of that newly inserted item. It is + * assumed that we are inserting before the "current item" + * + * @param item The item to be inserted + * @param insertBeforeItemId ID of the item that will be located immediately after the inserted + * and is assumed to be the "current item" + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + * @throws IllegalArgumentException + */ + public void queueInsertBeforeCurrentAndPlay(MediaQueueItem item, int insertBeforeItemId, + final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueInsertBeforeCurrentAndPlay"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to insert into queue with no active media session"); + throw new NoConnectionException(); + } + if (item == null || insertBeforeItemId == MediaQueueItem.INVALID_ITEM_ID) { + throw new IllegalArgumentException( + "item cannot be empty or insertBeforeItemId cannot be invalid"); + } + remoteMediaPlayer.queueInsertItems(mApiClient, new MediaQueueItem[]{item}, + insertBeforeItemId, customData).setResultCallback( + result -> { + if (result.getStatus().isSuccess()) { + + try { + queuePrev(customData); + } catch (TransientNetworkDisconnectionException | + NoConnectionException e) { + Log.e(TAG, "queuePrev() Failed to skip to previous", e); + } + } + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_INSERT_ITEMS, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Sets the repeat mode of the queue. + * + * @param repeatMode The repeat playback mode for the queue. + * @param customData Custom application-specific data to pass along with the request. May be + * {@code null}. + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void queueSetRepeatMode(final int repeatMode, final JSONObject customData) + throws TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "queueSetRepeatMode"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to update the queue with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer + .queueSetRepeatMode(mApiClient, repeatMode, customData).setResultCallback( + result -> { + if (!result.getStatus().isSuccess()) { + Log.d(TAG, "Failed with status: " + result.getStatus()); + } + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueOperationResult(QUEUE_OPERATION_SET_REPEAT, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Plays the loaded media. + * + * @param position Where to start the playback. Units is milliseconds. + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void play(int position) throws TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + Log.d(TAG, "attempting to play media at position " + position + " seconds"); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to play a video with no active media session"); + throw new NoConnectionException(); + } + seekAndPlay(position); + } + + /** + * Resumes the playback from where it was left (can be the beginning). + * + * @param customData Optional {@link JSONObject} data to be passed to the cast device + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void play(JSONObject customData) throws + TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "play(customData)"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to play a video with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer.play(mApiClient, customData) + .setResultCallback(result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_to_play, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Resumes the playback from where it was left (can be the beginning). + * + * @throws CastException + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void play() throws CastException, TransientNetworkDisconnectionException, + NoConnectionException { + play(null); + } + + /** + * Stops the playback of media/stream + * + * @param customData Optional {@link JSONObject} + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void stop(JSONObject customData) throws + TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "stop()"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to stop a stream with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer.stop(mApiClient, customData).setResultCallback( + result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_to_stop, + result.getStatus().getStatusCode()); + } + } + ); + } + + /** + * Stops the playback of media/stream + * + * @throws CastException + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void stop() throws CastException, + TransientNetworkDisconnectionException, NoConnectionException { + stop(null); + } + + /** + * Pauses the playback. + * + * @throws CastException + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void pause() throws CastException, TransientNetworkDisconnectionException, + NoConnectionException { + pause(null); + } + + /** + * Pauses the playback. + * + * @param customData Optional {@link JSONObject} data to be passed to the cast device + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void pause(JSONObject customData) throws + TransientNetworkDisconnectionException, NoConnectionException { + Log.d(TAG, "attempting to pause media"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to pause a video with no active media session"); + throw new NoConnectionException(); + } + remoteMediaPlayer.pause(mApiClient, customData) + .setResultCallback(result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_to_pause, + result.getStatus().getStatusCode()); + } + }); + } + + /** + * Seeks to the given point without changing the state of the player, i.e. after seek is + * completed, it resumes what it was doing before the start of seek. + * + * @param position in milliseconds + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void seek(int position) throws TransientNetworkDisconnectionException, + NoConnectionException { + Log.d(TAG, "attempting to seek media"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to seek a video with no active media session"); + throw new NoConnectionException(); + } + Log.d(TAG, "remoteMediaPlayer.seek() to position " + position); + remoteMediaPlayer.seek(mApiClient, + position, + RESUME_STATE_UNCHANGED). + setResultCallback(result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode()); + } + }); + } + + /** + * Fast forwards the media by the given amount. If {@code lengthInMillis} is negative, it + * rewinds the media. + * + * @param lengthInMillis The amount to fast forward the media, given in milliseconds + * @throws TransientNetworkDisconnectionException + * @throws NoConnectionException + */ + public void forward(int lengthInMillis) throws TransientNetworkDisconnectionException, + NoConnectionException { + Log.d(TAG, "forward(): attempting to forward media by " + lengthInMillis); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to seek a video with no active media session"); + throw new NoConnectionException(); + } + long position = remoteMediaPlayer.getApproximateStreamPosition() + lengthInMillis; + seek((int) position); + } + + /** + * Seeks to the given point and starts playback regardless of the starting state. + * + * @param position in milliseconds + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void seekAndPlay(int position) throws TransientNetworkDisconnectionException, + NoConnectionException { + Log.d(TAG, "attempting to seek media"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + Log.e(TAG, "Trying to seekAndPlay a video with no active media session"); + throw new NoConnectionException(); + } + Log.d(TAG, "remoteMediaPlayer.seek() to position " + position + "and play"); + remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_PLAY) + .setResultCallback(result -> { + if (!result.getStatus().isSuccess()) { + onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode()); + } + }); + } + + /** + * Toggles the playback of the media. + * + * @throws CastException + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public void togglePlayback() throws CastException, TransientNetworkDisconnectionException, + NoConnectionException { + checkConnectivity(); + boolean isPlaying = isRemoteMediaPlaying(); + if (isPlaying) { + pause(); + } else { + if (state == MediaStatus.PLAYER_STATE_IDLE + && idleReason == MediaStatus.IDLE_REASON_FINISHED) { + loadMedia(getRemoteMediaInformation(), true, 0); + } else { + play(); + } + } + } + + private void attachMediaChannel() throws TransientNetworkDisconnectionException, + NoConnectionException { + Log.d(TAG, "attachMediaChannel()"); + checkConnectivity(); + if (remoteMediaPlayer == null) { + remoteMediaPlayer = new RemoteMediaPlayer(); + + remoteMediaPlayer.setOnStatusUpdatedListener( + () -> { + Log.d(TAG, "RemoteMediaPlayer::onStatusUpdated() is reached"); + CastManager.this.onRemoteMediaPlayerStatusUpdated(); + } + ); + + remoteMediaPlayer.setOnPreloadStatusUpdatedListener( + () -> { + Log.d(TAG, "RemoteMediaPlayer::onPreloadStatusUpdated() is reached"); + CastManager.this.onRemoteMediaPreloadStatusUpdated(); + }); + + + remoteMediaPlayer.setOnMetadataUpdatedListener( + () -> { + Log.d(TAG, "RemoteMediaPlayer::onMetadataUpdated() is reached"); + CastManager.this.onRemoteMediaPlayerMetadataUpdated(); + } + ); + + remoteMediaPlayer.setOnQueueStatusUpdatedListener( + () -> { + Log.d(TAG, "RemoteMediaPlayer::onQueueStatusUpdated() is reached"); + mediaStatus = remoteMediaPlayer.getMediaStatus(); + if (mediaStatus != null + && mediaStatus.getQueueItems() != null) { + List<MediaQueueItem> queueItems = mediaStatus + .getQueueItems(); + int itemId = mediaStatus.getCurrentItemId(); + MediaQueueItem item = mediaStatus + .getQueueItemById(itemId); + int repeatMode = mediaStatus.getQueueRepeatMode(); + onQueueUpdated(queueItems, item, repeatMode, false); + } else { + onQueueUpdated(null, null, + MediaStatus.REPEAT_MODE_REPEAT_OFF, false); + } + }); + + } + try { + Log.d(TAG, "Registering MediaChannel namespace"); + Cast.CastApi.setMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace(), + remoteMediaPlayer); + } catch (IOException | IllegalStateException e) { + Log.e(TAG, "attachMediaChannel()", e); + } + } + + private void reattachMediaChannel() { + if (remoteMediaPlayer != null && mApiClient != null) { + try { + Log.d(TAG, "Registering MediaChannel namespace"); + Cast.CastApi.setMessageReceivedCallbacks(mApiClient, + remoteMediaPlayer.getNamespace(), remoteMediaPlayer); + } catch (IOException | IllegalStateException e) { + Log.e(TAG, "reattachMediaChannel()", e); + } + } + } + + private void detachMediaChannel() { + Log.d(TAG, "trying to detach media channel"); + if (remoteMediaPlayer != null) { + try { + Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, + remoteMediaPlayer.getNamespace()); + } catch (IOException | IllegalStateException e) { + Log.e(TAG, "detachMediaChannel()", e); + } + remoteMediaPlayer = null; + } + } + + /** + * Returns the playback status of the remote device. + * + * @return Returns one of the values + * <ul> + * <li> <code>MediaStatus.PLAYER_STATE_UNKNOWN</code></li> + * <li> <code>MediaStatus.PLAYER_STATE_IDLE</code></li> + * <li> <code>MediaStatus.PLAYER_STATE_PLAYING</code></li> + * <li> <code>MediaStatus.PLAYER_STATE_PAUSED</code></li> + * <li> <code>MediaStatus.PLAYER_STATE_BUFFERING</code></li> + * </ul> + */ + public int getPlaybackStatus() { + return state; + } + + /** + * Returns the latest retrieved value for the {@link MediaStatus}. This value is updated + * whenever the onStatusUpdated callback is called. + */ + public final MediaStatus getMediaStatus() { + return mediaStatus; + } + + /** + * Returns the Idle reason, defined in <code>MediaStatus.IDLE_*</code>. Note that the returned + * value is only meaningful if the status is truly <code>MediaStatus.PLAYER_STATE_IDLE + * </code> + * + * <p>Possible values are: + * <ul> + * <li>IDLE_REASON_NONE</li> + * <li>IDLE_REASON_FINISHED</li> + * <li>IDLE_REASON_CANCELED</li> + * <li>IDLE_REASON_INTERRUPTED</li> + * <li>IDLE_REASON_ERROR</li> + * </ul> + */ + public int getIdleReason() { + return idleReason; + } + + private void onMessageSendFailed(int errorCode) { + for (CastConsumer consumer : castConsumers) { + consumer.onDataMessageSendFailed(errorCode); + } + } + + /* + * This is called by onStatusUpdated() of the RemoteMediaPlayer + */ + private void onRemoteMediaPlayerStatusUpdated() { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated() reached"); + if (mApiClient == null || remoteMediaPlayer == null) { + Log.d(TAG, "mApiClient or remoteMediaPlayer is null, so will not proceed"); + return; + } + mediaStatus = remoteMediaPlayer.getMediaStatus(); + if (mediaStatus == null) { + Log.d(TAG, "MediaStatus is null, so will not proceed"); + return; + } else { + List<MediaQueueItem> queueItems = mediaStatus.getQueueItems(); + if (queueItems != null) { + int itemId = mediaStatus.getCurrentItemId(); + MediaQueueItem item = mediaStatus.getQueueItemById(itemId); + int repeatMode = mediaStatus.getQueueRepeatMode(); + onQueueUpdated(queueItems, item, repeatMode, false); + } else { + onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false); + } + state = mediaStatus.getPlayerState(); + idleReason = mediaStatus.getIdleReason(); + + if (state == MediaStatus.PLAYER_STATE_PLAYING) { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = playing"); + } else if (state == MediaStatus.PLAYER_STATE_PAUSED) { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = paused"); + } else if (state == MediaStatus.PLAYER_STATE_IDLE) { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = IDLE with reason: " + + idleReason); + if (idleReason == MediaStatus.IDLE_REASON_ERROR) { + // something bad happened on the cast device + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = ERROR"); + onFailed(R.string.cast_failed_receiver_player_error, NO_STATUS_CODE); + } + } else if (state == MediaStatus.PLAYER_STATE_BUFFERING) { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = buffering"); + } else { + Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = unknown"); + } + } + for (CastConsumer consumer : castConsumers) { + consumer.onRemoteMediaPlayerStatusUpdated(); + } + if (mediaStatus != null) { + double volume = mediaStatus.getStreamVolume(); + boolean isMute = mediaStatus.isMute(); + for (CastConsumer consumer : castConsumers) { + consumer.onStreamVolumeChanged(volume, isMute); + } + } + } + + private void onRemoteMediaPreloadStatusUpdated() { + MediaQueueItem item = null; + mediaStatus = remoteMediaPlayer.getMediaStatus(); + if (mediaStatus != null) { + item = mediaStatus.getQueueItemById(mediaStatus.getPreloadedItemId()); + } + preLoadingItem = item; + Log.d(TAG, "onRemoteMediaPreloadStatusUpdated() " + item); + for (CastConsumer consumer : castConsumers) { + consumer.onRemoteMediaPreloadStatusUpdated(item); + } + } + + public MediaQueueItem getPreLoadingItem() { + return preLoadingItem; + } + + /* + * This is called by onQueueStatusUpdated() of RemoteMediaPlayer + */ + private void onQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item, + int repeatMode, boolean shuffle) { + Log.d(TAG, "onQueueUpdated() reached"); + Log.d(TAG, String.format("Queue Items size: %d, Item: %s, Repeat Mode: %d, Shuffle: %s", + queueItems == null ? 0 : queueItems.size(), item, repeatMode, shuffle)); + if (queueItems != null) { + mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(queueItems), item, shuffle, + repeatMode); + } else { + mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(), null, false, + MediaStatus.REPEAT_MODE_REPEAT_OFF); + } + for (CastConsumer consumer : castConsumers) { + consumer.onMediaQueueUpdated(queueItems, item, repeatMode, shuffle); + } + } + + /* + * This is called by onMetadataUpdated() of RemoteMediaPlayer + */ + public void onRemoteMediaPlayerMetadataUpdated() { + Log.d(TAG, "onRemoteMediaPlayerMetadataUpdated() reached"); + for (CastConsumer consumer : castConsumers) { + consumer.onRemoteMediaPlayerMetadataUpdated(); + } + } + + /** + * Registers a {@link CastConsumer} interface with this class. + * Registered listeners will be notified of changes to a variety of + * lifecycle and media status changes through the callbacks that the interface provides. + * + * @see DefaultCastConsumer + */ + public synchronized void addCastConsumer(CastConsumer listener) { + if (listener != null) { + addBaseCastConsumer(listener); + castConsumers.add(listener); + Log.d(TAG, "Successfully added the new CastConsumer listener " + listener); + } + } + + /** + * Unregisters a {@link CastConsumer}. + */ + public synchronized void removeCastConsumer(CastConsumer listener) { + if (listener != null) { + removeBaseCastConsumer(listener); + castConsumers.remove(listener); + } + } + + @Override + protected void onDeviceUnselected() { + detachMediaChannel(); + //removeDataChannel(); + state = MediaStatus.PLAYER_STATE_IDLE; + mediaStatus = null; + } + + @Override + protected Cast.CastOptions.Builder getCastOptionBuilder(CastDevice device) { + Cast.CastOptions.Builder builder = new Cast.CastOptions.Builder(mSelectedCastDevice, new CastListener()); + if (isFeatureEnabled(CastConfiguration.FEATURE_DEBUGGING)) { + builder.setVerboseLoggingEnabled(true); + } + return builder; + } + + @Override + public void onConnectionFailed(ConnectionResult result) { + super.onConnectionFailed(result); + state = MediaStatus.PLAYER_STATE_IDLE; + mediaStatus = null; + } + + @Override + public void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData, + boolean setDefaultRoute) { + super.onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute); + state = MediaStatus.PLAYER_STATE_IDLE; + mediaStatus = null; + mediaQueue = null; + } + + class CastListener extends Cast.Listener { + + /* + * (non-Javadoc) + * @see com.google.android.gms.cast.Cast.Listener#onApplicationDisconnected (int) + */ + @Override + public void onApplicationDisconnected(int statusCode) { + CastManager.this.onApplicationDisconnected(statusCode); + } + + /* + * (non-Javadoc) + * @see com.google.android.gms.cast.Cast.Listener#onApplicationStatusChanged () + */ + @Override + public void onApplicationStatusChanged() { + CastManager.this.onApplicationStatusChanged(); + } + + @Override + public void onVolumeChanged() { + CastManager.this.onDeviceVolumeChanged(); + } + } + + @Override + public void onFailed(int resourceId, int statusCode) { + Log.d(TAG, "onFailed: " + mContext.getString(resourceId) + ", code: " + statusCode); + super.onFailed(resourceId, statusCode); + } + + /** + * Clients can call this method to delegate handling of the volume. Clients should override + * {@code dispatchEvent} and call this method: + * <pre> + public boolean dispatchKeyEvent(KeyEvent event) { + if (mCastManager.onDispatchVolumeKeyEvent(event, VOLUME_DELTA)) { + return true; + } + return super.dispatchKeyEvent(event); + } + * </pre> + * @param event The dispatched event. + * @param volumeDelta The amount by which volume should be increased or decreased in each step + * @return <code>true</code> if volume is handled by the library, <code>false</code> otherwise. + */ + public boolean onDispatchVolumeKeyEvent(KeyEvent event, double volumeDelta) { + if (isConnected()) { + boolean isKeyDown = event.getAction() == KeyEvent.ACTION_DOWN; + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_VOLUME_UP: + return changeVolume(volumeDelta, isKeyDown); + case KeyEvent.KEYCODE_VOLUME_DOWN: + return changeVolume(-volumeDelta, isKeyDown); + } + } + return false; + } + + private boolean changeVolume(double volumeIncrement, boolean isKeyDown) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) + && getPlaybackStatus() == MediaStatus.PLAYER_STATE_PLAYING + && isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) { + return false; + } + + if (isKeyDown) { + try { + adjustDeviceVolume(volumeIncrement); + } catch (CastException | TransientNetworkDisconnectionException | + NoConnectionException e) { + Log.e(TAG, "Failed to change volume", e); + } + } + return true; + } + + /** + * Sets the volume step, i.e. the fraction by which volume will increase or decrease each time + * user presses the hard volume buttons on the device. + * + * @param volumeStep Should be a double between 0 and 1, inclusive. + */ + public CastManager setVolumeStep(double volumeStep) { + if ((volumeStep > 1) || (volumeStep < 0)) { + throw new IllegalArgumentException("Volume Step should be between 0 and 1, inclusive"); + } + this.volumeStep = volumeStep; + return this; + } + + /** + * Returns the volume step. The default value is {@code DEFAULT_VOLUME_STEP}. + */ + public double getVolumeStep() { + return volumeStep; + } + + public final MediaQueue getMediaQueue() { + return mediaQueue; + } + + /** + * Checks whether the selected Cast Device has the specified audio or video capabilities. + * + * @param capability capability from: + * <ul> + * <li>{@link CastDevice#CAPABILITY_AUDIO_IN}</li> + * <li>{@link CastDevice#CAPABILITY_AUDIO_OUT}</li> + * <li>{@link CastDevice#CAPABILITY_VIDEO_IN}</li> + * <li>{@link CastDevice#CAPABILITY_VIDEO_OUT}</li> + * </ul> + * @param defaultVal value to return whenever there's no device selected. + * @return {@code true} if the selected device has the specified capability, + * {@code false} otherwise. + */ + public boolean hasCapability(final int capability, final boolean defaultVal) { + if (mSelectedCastDevice != null) { + return mSelectedCastDevice.hasCapability(capability); + } else { + return defaultVal; + } + } + + /** + * Adds and wires up the Switchable Media Router cast button. It returns a reference to the + * {@link SwitchableMediaRouteActionProvider} associated with the button if the caller needs + * such reference. It is assumed that the enclosing + * {@link android.app.Activity} inherits (directly or indirectly) from + * {@link android.support.v7.app.AppCompatActivity}. + * + * @param menuItem MenuItem of the Media Router cast button. + */ + public final SwitchableMediaRouteActionProvider addMediaRouterButton(MenuItem menuItem) { + SwitchableMediaRouteActionProvider mediaRouteActionProvider = (SwitchableMediaRouteActionProvider) + MenuItemCompat.getActionProvider(menuItem); + mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector); + if (mCastConfiguration.getMediaRouteDialogFactory() != null) { + mediaRouteActionProvider.setDialogFactory(mCastConfiguration.getMediaRouteDialogFactory()); + } + return mediaRouteActionProvider; + } + + /* (non-Javadoc) + * These methods startReconnectionService and stopReconnectionService simply override the ones + * from BaseCastManager with empty implementations because we handle the service ourselves, but + * need to allow BaseCastManager to save current network information. + */ + @Override + protected void startReconnectionService(long mediaDurationLeft) { + // Do nothing + } + + @Override + protected void stopReconnectionService() { + // Do nothing + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java b/core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java new file mode 100644 index 000000000..f0a7214c9 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java @@ -0,0 +1,317 @@ +package de.danoeh.antennapod.core.cast; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +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.common.images.WebImage; + +import java.util.Calendar; +import java.util.List; + +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.util.playback.ExternalMedia; +import de.danoeh.antennapod.core.util.playback.Playable; + +/** + * Helper functions for Cast support. + */ +public class 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_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"; + public static final int EPISODE_NOTES_MAX_LENGTH = Integer.MAX_VALUE; + + /** + * 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){ + if (media == null || media instanceof ExternalMedia) { + return false; + } + if (media instanceof FeedMedia || media instanceof RemoteMedia){ + String url = media.getStreamUrl(); + if(url == null || url.isEmpty()){ + return false; + } + switch (media.getMediaType()) { + case UNKNOWN: + return false; + case AUDIO: + return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT, true); + case VIDEO: + return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT, true); + } + } + return false; + } + + /** + * Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device. + * Before using this method, one should make sure {@link #isCastable(Playable)} returns + * {@code true}. + * + * Unless media.{@link FeedMedia#loadMetadata() loadMetadata()} has already been called, + * 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 convertFromFeedMedia(FeedMedia media){ + if(media == null) { + return null; + } + MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + try{ + media.loadMetadata(); + } catch (Playable.PlayableException e) { + Log.e(TAG, "Unable to load FeedMedia metadata", e); + } + 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); + } + FeedImage image = feedItem.getImage(); + if (image != null && !TextUtils.isEmpty(image.getDownload_url())) { + metadata.addImage(new WebImage(Uri.parse(image.getDownload_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(KEY_FEED_URL, feed.getDownload_url()); + } + if (!TextUtils.isEmpty(feed.getLink())) { + metadata.putString(KEY_FEED_WEBSITE, feed.getLink()); + } + } + if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) { + metadata.putString(KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier()); + } else { + metadata.putString(KEY_EPISODE_IDENTIFIER, media.getStreamUrl()); + } + if (!TextUtils.isEmpty(feedItem.getLink())) { + metadata.putString(KEY_EPISODE_LINK, feedItem.getLink()); + } + } + String notes = null; + try { + notes = media.loadShownotes().call(); + } catch (Exception e) { + Log.e(TAG, "Unable to load FeedMedia notes", e); + } + if (notes != null) { + if (notes.length() > EPISODE_NOTES_MAX_LENGTH) { + notes = notes.substring(0, EPISODE_NOTES_MAX_LENGTH); + } + metadata.putString(KEY_EPISODE_NOTES, notes); + } + // 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(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(KEY_FORMAT_VERSION, FORMAT_VERSION_VALUE); + + 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(); + } + + //TODO make unit tests for all the conversion methods + /** + * Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}. + * + * Unless <code>searchFeedMedia</code> is set to <code>false</code>, this method should not run + * on the GUI thread. + * + * @param media The {@link MediaInfo} object to be converted. + * @param searchFeedMedia If set to <code>true</code>, the database will be queried to find a + * {@link FeedMedia} instance that matches {@param media}. + * @return {@link Playable} object in a format proper for casting. + */ + public static Playable getPlayable(MediaInfo media, boolean searchFeedMedia) { + Log.d(TAG, "getPlayable called with searchFeedMedia=" + searchFeedMedia); + if (media == null) { + Log.d(TAG, "MediaInfo object provided is null, not converting to any Playable instance"); + return null; + } + 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; + } + Playable result = null; + if (searchFeedMedia) { + long mediaId = metadata.getInt(KEY_MEDIA_ID); + if (mediaId > 0) { + FeedMedia fMedia = DBReader.getFeedMedia(mediaId); + if (fMedia != null) { + try { + fMedia.loadMetadata(); + if (matches(media, fMedia)) { + result = fMedia; + Log.d(TAG, "FeedMedia object obtained matches the MediaInfo provided. id=" + mediaId); + } else { + Log.d(TAG, "FeedMedia object obtained does NOT match the MediaInfo provided. id=" + mediaId); + } + } catch (Playable.PlayableException e) { + Log.e(TAG, "Unable to load FeedMedia metadata to compare with MediaInfo", e); + } + } else { + Log.d(TAG, "Unable to find in database a FeedMedia with id=" + mediaId); + } + } + if (result == null) { + FeedItem feedItem = DBReader.getFeedItem(metadata.getString(KEY_FEED_URL), + metadata.getString(KEY_EPISODE_IDENTIFIER)); + if (feedItem != null) { + result = feedItem.getMedia(); + Log.d(TAG, "Found episode that matches the MediaInfo provided. Using its media, if existing."); + } + } + } + if (result == null) { + List<WebImage> imageList = metadata.getImages(); + String imageUrl = null; + if (!imageList.isEmpty()) { + imageUrl = imageList.get(0).getUrl().toString(); + } + 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()); + String notes = metadata.getString(KEY_EPISODE_NOTES); + if (!TextUtils.isEmpty(notes)) { + ((RemoteMedia) result).setNotes(notes); + } + Log.d(TAG, "Converted MediaInfo into RemoteMedia"); + } + 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); + } + + + //TODO Queue handling perhaps +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java b/core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java new file mode 100644 index 000000000..fe4183d54 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java @@ -0,0 +1,10 @@ +package de.danoeh.antennapod.core.cast; + +import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl; + +public class DefaultCastConsumer extends VideoCastConsumerImpl implements CastConsumer { + @Override + public void onStreamVolumeChanged(double value, boolean isMute) { + // no-op + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java b/core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java new file mode 100644 index 000000000..99f7b9496 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java @@ -0,0 +1,347 @@ +package de.danoeh.antennapod.core.cast; + +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +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 java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.util.ChapterUtils; +import de.danoeh.antennapod.core.util.playback.Playable; + +/** + * Playable implementation for media on a Cast Device for which a local version of + * {@link de.danoeh.antennapod.core.feed.FeedMedia} hasn't been found. + */ +public class RemoteMedia implements Playable { + public static final String TAG = "RemoteMedia"; + + public static final int PLAYABLE_TYPE_REMOTE_MEDIA = 3; + + private String downloadUrl; + private String itemIdentifier; + private String feedUrl; + private String feedTitle; + private String episodeTitle; + private String episodeLink; + private String feedAuthor; + private String imageUrl; + private String feedLink; + private String mime_type; + private Date pubDate; + private String notes; + private List<Chapter> chapters; + private int duration; + private int position; + private long lastPlayedTime; + + public RemoteMedia(String downloadUrl, String itemId, String feedUrl, String feedTitle, + String episodeTitle, String episodeLink, String feedAuthor, + String imageUrl, String feedLink, String mime_type, Date pubDate) { + this.downloadUrl = downloadUrl; + this.itemIdentifier = itemId; + this.feedUrl = feedUrl; + this.feedTitle = feedTitle; + this.episodeTitle = episodeTitle; + this.episodeLink = episodeLink; + this.feedAuthor = feedAuthor; + this.imageUrl = imageUrl; + this.feedLink = feedLink; + this.mime_type = mime_type; + this.pubDate = pubDate; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public MediaInfo extractMediaInfo() { + MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + + metadata.putString(MediaMetadata.KEY_TITLE, episodeTitle); + metadata.putString(MediaMetadata.KEY_SUBTITLE, feedTitle); + if (!TextUtils.isEmpty(imageUrl)) { + metadata.addImage(new WebImage(Uri.parse(imageUrl))); + } + Calendar calendar = Calendar.getInstance(); + calendar.setTime(pubDate); + metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar); + if (!TextUtils.isEmpty(feedAuthor)) { + metadata.putString(MediaMetadata.KEY_ARTIST, feedAuthor); + } + if (!TextUtils.isEmpty(feedUrl)) { + metadata.putString(CastUtils.KEY_FEED_URL, feedUrl); + } + if (!TextUtils.isEmpty(feedLink)) { + metadata.putString(CastUtils.KEY_FEED_WEBSITE, feedLink); + } + if (!TextUtils.isEmpty(itemIdentifier)) { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, itemIdentifier); + } else { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, downloadUrl); + } + if (!TextUtils.isEmpty(episodeLink)) { + metadata.putString(CastUtils.KEY_EPISODE_LINK, episodeLink); + } + String notes = this.notes; + if (notes != null) { + if (notes.length() > CastUtils.EPISODE_NOTES_MAX_LENGTH) { + notes = notes.substring(0, CastUtils.EPISODE_NOTES_MAX_LENGTH); + } + 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); + + MediaInfo.Builder builder = new MediaInfo.Builder(downloadUrl) + .setContentType(mime_type) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setMetadata(metadata); + if (duration > 0) { + builder.setStreamDuration(duration); + } + return builder.build(); + } + + public String getEpisodeIdentifier() { + return itemIdentifier; + } + + public String getFeedUrl() { + return feedUrl; + } + + public FeedMedia lookForFeedMedia() { + FeedItem feedItem = DBReader.getFeedItem(feedUrl, itemIdentifier); + if (feedItem == null) { + return null; + } + return feedItem.getMedia(); + } + + @Override + public void writeToPreferences(SharedPreferences.Editor prefEditor) { + //it seems pointless to do it, since the session should be kept by the remote device. + } + + @Override + public void loadMetadata() throws PlayableException { + //Already loaded + } + + @Override + public void loadChapterMarks() { + ChapterUtils.loadChaptersFromStreamUrl(this); + } + + @Override + public String getEpisodeTitle() { + return episodeTitle; + } + + @Override + public List<Chapter> getChapters() { + return chapters; + } + + @Override + public String getWebsiteLink() { + if (episodeLink != null) { + return episodeLink; + } else { + return feedUrl; + } + } + + @Override + public String getPaymentLink() { + return null; + } + + @Override + public String getFeedTitle() { + return feedTitle; + } + + @Override + public Object getIdentifier() { + return itemIdentifier + "@" + feedUrl; + } + + @Override + public int getDuration() { + return duration; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public long getLastPlayedTime() { + return lastPlayedTime; + } + + @Override + public MediaType getMediaType() { + return MediaType.fromMimeType(mime_type); + } + + @Override + public String getLocalMediaUrl() { + return null; + } + + @Override + public String getStreamUrl() { + return downloadUrl; + } + + @Override + public boolean localFileAvailable() { + return false; + } + + @Override + public boolean streamAvailable() { + return true; + } + + @Override + public void saveCurrentPosition(SharedPreferences pref, int newPosition, long timestamp) { + //we're not saving playback information for this kind of items on preferences + setPosition(newPosition); + setLastPlayedTime(timestamp); + } + + @Override + public void setPosition(int newPosition) { + position = newPosition; + } + + @Override + public void setDuration(int newDuration) { + duration = newDuration; + } + + @Override + public void setLastPlayedTime(long lastPlayedTimestamp) { + lastPlayedTime = lastPlayedTimestamp; + } + + @Override + public void onPlaybackStart() { + // no-op + } + + @Override + public void onPlaybackCompleted() { + // no-op + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_REMOTE_MEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + this.chapters = chapters; + } + + @Override + public Uri getImageUri() { + if (imageUrl != null) { + return Uri.parse(imageUrl); + } + return null; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public Callable<String> loadShownotes() { + return () -> (notes != null) ? notes : ""; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(downloadUrl); + dest.writeString(itemIdentifier); + dest.writeString(feedUrl); + dest.writeString(feedTitle); + dest.writeString(episodeTitle); + dest.writeString(episodeLink); + dest.writeString(feedAuthor); + dest.writeString(imageUrl); + dest.writeString(feedLink); + dest.writeString(mime_type); + dest.writeLong(pubDate.getTime()); + dest.writeString(notes); + dest.writeInt(duration); + dest.writeInt(position); + dest.writeLong(lastPlayedTime); + } + + public static final Parcelable.Creator<RemoteMedia> CREATOR = new Parcelable.Creator<RemoteMedia>() { + @Override + public RemoteMedia createFromParcel(Parcel in) { + RemoteMedia result = new RemoteMedia(in.readString(), in.readString(), in.readString(), + in.readString(), in.readString(), in.readString(), in.readString(), in.readString(), + in.readString(), in.readString(), new Date(in.readLong())); + result.setNotes(in.readString()); + result.setDuration(in.readInt()); + result.setPosition(in.readInt()); + result.setLastPlayedTime(in.readLong()); + return result; + } + + @Override + public RemoteMedia[] newArray(int size) { + return new RemoteMedia[size]; + } + }; + + @Override + public boolean equals(Object other) { + if (other instanceof RemoteMedia) { + RemoteMedia rm = (RemoteMedia) other; + return TextUtils.equals(downloadUrl, rm.downloadUrl) && + TextUtils.equals(feedUrl, rm.feedUrl) && + TextUtils.equals(itemIdentifier, rm.itemIdentifier); + } + if (other instanceof FeedMedia) { + FeedMedia fm = (FeedMedia) other; + if (!TextUtils.equals(downloadUrl, fm.getStreamUrl())) { + return false; + } + FeedItem fi = fm.getItem(); + if (fi == null || !TextUtils.equals(itemIdentifier, fi.getItemIdentifier())) { + return false; + } + Feed feed = fi.getFeed(); + return feed != null && TextUtils.equals(feedUrl, feed.getDownload_url()); + } + return false; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java b/core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java new file mode 100644 index 000000000..f063cf5e3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java @@ -0,0 +1,106 @@ +package de.danoeh.antennapod.core.cast; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.support.v4.app.FragmentActivity; +import android.support.v4.app.FragmentManager; +import android.support.v7.app.MediaRouteActionProvider; +import android.support.v7.app.MediaRouteChooserDialogFragment; +import android.support.v7.app.MediaRouteControllerDialogFragment; +import android.support.v7.media.MediaRouter; +import android.util.Log; + +/** + * <p>Action Provider that extends {@link MediaRouteActionProvider} and allows the client to + * disable completely the button by calling {@link #setEnabled(boolean)}.</p> + * + * <p>It is disabled by default, so if a client wants to initially have it enabled it must call + * <code>setEnabled(true)</code>.</p> + */ +public class SwitchableMediaRouteActionProvider extends MediaRouteActionProvider { + public static final String TAG = "SwitchblMediaRtActProv"; + + private static final String CHOOSER_FRAGMENT_TAG = + "android.support.v7.mediarouter:MediaRouteChooserDialogFragment"; + private static final String CONTROLLER_FRAGMENT_TAG = + "android.support.v7.mediarouter:MediaRouteControllerDialogFragment"; + private boolean enabled; + + public SwitchableMediaRouteActionProvider(Context context) { + super(context); + enabled = false; + } + + /** + * <p>Sets whether the Media Router button should be allowed to become visible or not.</p> + * + * <p>It's invisible by default.</p> + */ + public void setEnabled(boolean newVal) { + enabled = newVal; + refreshVisibility(); + } + + @Override + public boolean isVisible() { + return enabled && super.isVisible(); + } + + @Override + public boolean onPerformDefaultAction() { + if (!super.onPerformDefaultAction()) { + // there is no button, but we should still show the dialog if it's the case. + if (!isVisible()) { + return false; + } + FragmentManager fm = getFragmentManager(); + if (fm == null) { + return false; + } + MediaRouter.RouteInfo route = MediaRouter.getInstance(getContext()).getSelectedRoute(); + if (route.isDefault() || !route.matchesSelector(getRouteSelector())) { + if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) { + Log.w(TAG, "showDialog(): Route chooser dialog already showing!"); + return false; + } + MediaRouteChooserDialogFragment f = + getDialogFactory().onCreateChooserDialogFragment(); + f.setRouteSelector(getRouteSelector()); + f.show(fm, CHOOSER_FRAGMENT_TAG); + } else { + if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) { + Log.w(TAG, "showDialog(): Route controller dialog already showing!"); + return false; + } + MediaRouteControllerDialogFragment f = + getDialogFactory().onCreateControllerDialogFragment(); + f.show(fm, CONTROLLER_FRAGMENT_TAG); + } + return true; + + } else { + return true; + } + } + + private FragmentManager getFragmentManager() { + Activity activity = getActivity(); + if (activity instanceof FragmentActivity) { + return ((FragmentActivity)activity).getSupportFragmentManager(); + } + return null; + } + + private Activity getActivity() { + // Gross way of unwrapping the Activity so we can get the FragmentManager + Context context = getContext(); + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity)context; + } + context = ((ContextWrapper)context).getBaseContext(); + } + return null; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java index 991499316..7f064fff3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java @@ -13,6 +13,7 @@ import java.util.Date; import java.util.List; import java.util.concurrent.Callable; +import de.danoeh.antennapod.core.cast.RemoteMedia; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.DBReader; @@ -152,18 +153,7 @@ public class FeedMedia extends FeedFile implements Playable { * Uses mimetype to determine the type of media. */ public MediaType getMediaType() { - if (mime_type == null || mime_type.isEmpty()) { - return MediaType.UNKNOWN; - } else { - if (mime_type.startsWith("audio")) { - return MediaType.AUDIO; - } else if (mime_type.startsWith("video")) { - return MediaType.VIDEO; - } else if (mime_type.equals("application/ogg")) { - return MediaType.AUDIO; - } - } - return MediaType.UNKNOWN; + return MediaType.fromMimeType(mime_type); } public void updateFromOther(FeedMedia other) { @@ -579,4 +569,12 @@ public class FeedMedia extends FeedFile implements Playable { hasEmbeddedPicture = Boolean.FALSE; } } + + @Override + public boolean equals(Object o) { + if (o instanceof RemoteMedia) { + return o.equals(this); + } + return super.equals(o); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java b/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java index 7b3cb829d..83ac031bf 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java @@ -1,5 +1,20 @@ package de.danoeh.antennapod.core.feed; +import android.text.TextUtils; + public enum MediaType { - AUDIO, VIDEO, UNKNOWN + AUDIO, VIDEO, UNKNOWN; + + public static MediaType fromMimeType(String mime_type) { + if (TextUtils.isEmpty(mime_type)) { + return MediaType.UNKNOWN; + } else if (mime_type.startsWith("audio")) { + return MediaType.AUDIO; + } else if (mime_type.startsWith("video")) { + return MediaType.VIDEO; + } else if (mime_type.equals("application/ogg")) { + return MediaType.AUDIO; + } + return MediaType.UNKNOWN; + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java index 569dfd2c4..b5bbb0350 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java @@ -111,6 +111,7 @@ public class UserPreferences { public static final String PREF_SONIC = "prefSonic"; public static final String PREF_STEREO_TO_MONO = "PrefStereoToMono"; public static final String PREF_NORMALIZER = "prefNormalizer"; + public static final String PREF_CAST_ENABLED = "prefCast"; //Used for enabling Chromecast support public static final int EPISODE_CLEANUP_QUEUE = -1; public static final int EPISODE_CLEANUP_NULL = -2; public static final int EPISODE_CLEANUP_DEFAULT = 0; @@ -800,4 +801,11 @@ public class UserPreferences { public static int readEpisodeCacheSize(String valueFromPrefs) { return readEpisodeCacheSizeInternal(valueFromPrefs); } + + /** + * Evaluates whether Cast support (Chromecast, Audio Cast, etc) is enabled on the preferences. + */ + public static boolean isCastEnabled() { + return prefs.getBoolean(PREF_CAST_ENABLED, false); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java new file mode 100644 index 000000000..be80ea112 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java @@ -0,0 +1,894 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.content.Context; +import android.media.AudioManager; +import android.os.PowerManager; +import android.support.annotation.NonNull; +import android.telephony.TelephonyManager; +import android.util.Log; +import android.util.Pair; +import android.view.SurfaceHolder; + +import org.antennapod.audio.MediaPlayer; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; +import de.danoeh.antennapod.core.util.playback.AudioPlayer; +import de.danoeh.antennapod.core.util.playback.IPlayer; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.VideoPlayer; + +/** + * Manages the MediaPlayer object of the PlaybackService. + */ +public class LocalPSMP extends PlaybackServiceMediaPlayer { + public static final String TAG = "LclPlaybackSvcMPlayer"; + + private final AudioManager audioManager; + + private volatile PlayerStatus statusBeforeSeeking; + private volatile IPlayer mediaPlayer; + private volatile Playable media; + + private volatile boolean stream; + private volatile MediaType mediaType; + private volatile AtomicBoolean startWhenPrepared; + private volatile boolean pausedBecauseOfTransientAudiofocusLoss; + private volatile Pair<Integer, Integer> videoSize; + + /** + * Some asynchronous calls might change the state of the MediaPlayer object. Therefore calls in other threads + * have to wait until these operations have finished. + */ + private final ReentrantLock playerLock; + private CountDownLatch seekLatch; + + private final ThreadPoolExecutor executor; + + public LocalPSMP(@NonNull Context context, + @NonNull PSMPCallback callback) { + super(context, callback); + + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + this.playerLock = new ReentrantLock(); + this.startWhenPrepared = new AtomicBoolean(false); + executor = new ThreadPoolExecutor(1, 1, 5, TimeUnit.MINUTES, new LinkedBlockingDeque<>(), + (r, executor) -> Log.d(TAG, "Rejected execution of runnable")); + + mediaPlayer = null; + statusBeforeSeeking = null; + pausedBecauseOfTransientAudiofocusLoss = false; + mediaType = MediaType.UNKNOWN; + videoSize = null; + } + + /** + * 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. + */ + @Override + public void playMediaObject(@NonNull final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + Log.d(TAG, "playMediaObject(...)"); + executor.submit(() -> { + playerLock.lock(); + try { + playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately); + } catch (RuntimeException e) { + e.printStackTrace(); + throw e; + } finally { + playerLock.unlock(); + } + }); + } + + /** + * 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. + * <p/> + * This method requires the playerLock and is executed on the caller's thread. + * + * @see #playMediaObject(de.danoeh.antennapod.core.util.playback.Playable, boolean, boolean, boolean) + */ + private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + if (!playerLock.isHeldByCurrentThread()) { + throw new IllegalStateException("method requires playerLock"); + } + + + 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 { + // stop playback of this episode + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) { + mediaPlayer.stop(); + } + // set temporarily to pause in order to update list with current position + if (playerStatus == PlayerStatus.PLAYING) { + setPlayerStatus(PlayerStatus.PAUSED, media); + } + + smartMarkAsPlayed(media); + + setPlayerStatus(PlayerStatus.INDETERMINATE, null); + } + } + + this.media = playable; + this.stream = stream; + this.mediaType = media.getMediaType(); + this.videoSize = null; + createMediaPlayer(); + LocalPSMP.this.startWhenPrepared.set(startWhenPrepared); + setPlayerStatus(PlayerStatus.INITIALIZING, media); + try { + media.loadMetadata(); + callback.reloadUI(); + if (stream) { + mediaPlayer.setDataSource(media.getStreamUrl()); + } else { + mediaPlayer.setDataSource(media.getLocalMediaUrl()); + } + setPlayerStatus(PlayerStatus.INITIALIZED, media); + + if (prepareImmediately) { + setPlayerStatus(PlayerStatus.PREPARING, media); + mediaPlayer.prepare(); + onPrepared(startWhenPrepared); + } + + } catch (Playable.PlayableException | IOException | IllegalStateException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } + } + + /** + * 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. + */ + @Override + public void resume() { + executor.submit(() -> { + playerLock.lock(); + resumeSync(); + playerLock.unlock(); + }); + } + + private void resumeSync() { + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) { + int focusGained = audioManager.requestAudioFocus( + audioFocusChangeListener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + acquireWifiLockIfNecessary(); + float speed = 1.0f; + try { + speed = Float.parseFloat(UserPreferences.getPlaybackSpeed()); + } catch(NumberFormatException e) { + Log.e(TAG, Log.getStackTraceString(e)); + UserPreferences.setPlaybackSpeed(String.valueOf(speed)); + } + setSpeed(speed); + setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); + + if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { + int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( + media.getPosition(), + media.getLastPlayedTime()); + seekToSync(newPosition); + } + mediaPlayer.start(); + + setPlayerStatus(PlayerStatus.PLAYING, media); + pausedBecauseOfTransientAudiofocusLoss = false; + media.onPlaybackStart(); + + } else { + Log.e(TAG, "Failed to request audio focus"); + } + } else { + Log.d(TAG, "Call to resume() was ignored because current state of PSMP object is " + playerStatus); + } + } + + + /** + * 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 + */ + @Override + public void pause(final boolean abandonFocus, final boolean reinit) { + executor.submit(() -> { + playerLock.lock(); + releaseWifiLockIfNecessary(); + if (playerStatus == PlayerStatus.PLAYING) { + Log.d(TAG, "Pausing playback."); + mediaPlayer.pause(); + setPlayerStatus(PlayerStatus.PAUSED, media); + + if (abandonFocus) { + audioManager.abandonAudioFocus(audioFocusChangeListener); + pausedBecauseOfTransientAudiofocusLoss = false; + } + if (stream && reinit) { + reinit(); + } + } else { + Log.d(TAG, "Ignoring call to pause: Player is in " + playerStatus + " state"); + } + + playerLock.unlock(); + }); + } + + /** + * Prepares media player for playback if the service is in the INITALIZED + * state. + * <p/> + * This method is executed on an internal executor service. + */ + @Override + public void prepare() { + executor.submit(() -> { + playerLock.lock(); + + if (playerStatus == PlayerStatus.INITIALIZED) { + Log.d(TAG, "Preparing media player"); + setPlayerStatus(PlayerStatus.PREPARING, media); + try { + mediaPlayer.prepare(); + onPrepared(startWhenPrepared.get()); + } catch (IOException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } + } + playerLock.unlock(); + + }); + } + + /** + * Called after media player has been prepared. This method is executed on the caller's thread. + */ + void onPrepared(final boolean startWhenPrepared) { + playerLock.lock(); + + if (playerStatus != PlayerStatus.PREPARING) { + playerLock.unlock(); + throw new IllegalStateException("Player is not in PREPARING state"); + } + + Log.d(TAG, "Resource prepared"); + + if (mediaType == MediaType.VIDEO) { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + videoSize = new Pair<>(vp.getVideoWidth(), vp.getVideoHeight()); + } + + if (media.getPosition() > 0) { + seekToSync(media.getPosition()); + } + + if (media.getDuration() == 0) { + Log.d(TAG, "Setting duration of media"); + media.setDuration(mediaPlayer.getDuration()); + } + setPlayerStatus(PlayerStatus.PREPARED, media); + + if (startWhenPrepared) { + resumeSync(); + } + + playerLock.unlock(); + } + + /** + * Resets the media player and moves it into INITIALIZED state. + * <p/> + * This method is executed on an internal executor service. + */ + @Override + public void reinit() { + executor.submit(() -> { + playerLock.lock(); + releaseWifiLockIfNecessary(); + if (media != null) { + playMediaObject(media, true, stream, startWhenPrepared.get(), false); + } else if (mediaPlayer != null) { + mediaPlayer.reset(); + } else { + Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null"); + } + playerLock.unlock(); + }); + } + + + /** + * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. + * + * @param t The position to seek to in milliseconds. t < 0 will be interpreted as t = 0 + * <p/> + * This method is executed on the caller's thread. + */ + private void seekToSync(int t) { + if (t < 0) { + t = 0; + } + playerLock.lock(); + + if (playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) { + if (!stream) { + statusBeforeSeeking = playerStatus; + setPlayerStatus(PlayerStatus.SEEKING, media); + } + if(seekLatch != null && seekLatch.getCount() > 0) { + try { + seekLatch.await(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Log.e(TAG, Log.getStackTraceString(e)); + } + } + seekLatch = new CountDownLatch(1); + mediaPlayer.seekTo(t); + try { + seekLatch.await(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Log.e(TAG, Log.getStackTraceString(e)); + } + } else if (playerStatus == PlayerStatus.INITIALIZED) { + media.setPosition(t); + startWhenPrepared.set(false); + prepare(); + } + playerLock.unlock(); + } + + /** + * 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. + */ + @Override + public void seekTo(final int t) { + executor.submit(() -> seekToSync(t)); + } + + /** + * Seek a specific position from the current position + * + * @param d offset from current position (positive or negative) + */ + @Override + public void seekDelta(final int d) { + executor.submit(() -> { + playerLock.lock(); + int currentPosition = getPosition(); + if (currentPosition != INVALID_TIME) { + seekToSync(currentPosition + d); + } else { + Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); + } + + playerLock.unlock(); + }); + } + + /** + * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved. + */ + @Override + public int getDuration() { + if (!playerLock.tryLock()) { + return INVALID_TIME; + } + + int retVal = INVALID_TIME; + if (playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) { + retVal = mediaPlayer.getDuration(); + } else if (media != null && media.getDuration() > 0) { + retVal = media.getDuration(); + } + + playerLock.unlock(); + return retVal; + } + + /** + * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved. + */ + @Override + public int getPosition() { + try { + if (!playerLock.tryLock(50, TimeUnit.MILLISECONDS)) { + return INVALID_TIME; + } + } catch (InterruptedException e) { + return INVALID_TIME; + } + + int retVal = INVALID_TIME; + if (playerStatus.isAtLeast(PlayerStatus.PREPARED)) { + retVal = mediaPlayer.getCurrentPosition(); + } + if (retVal <= 0 && media != null && media.getPosition() >= 0) { + retVal = media.getPosition(); + } + + playerLock.unlock(); + Log.d(TAG, "getPosition() -> " + retVal); + return retVal; + } + + @Override + public boolean isStartWhenPrepared() { + return startWhenPrepared.get(); + } + + @Override + public void setStartWhenPrepared(boolean startWhenPrepared) { + this.startWhenPrepared.set(startWhenPrepared); + } + + /** + * Returns true if the playback speed can be adjusted. + */ + @Override + public boolean canSetSpeed() { + boolean retVal = false; + if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) { + retVal = (mediaPlayer).canSetSpeed(); + } + return retVal; + } + + /** + * Sets the playback speed. + * This method is executed on the caller's thread. + */ + private void setSpeedSync(float speed) { + playerLock.lock(); + if (media != null && media.getMediaType() == MediaType.AUDIO) { + if (mediaPlayer.canSetSpeed()) { + mediaPlayer.setPlaybackSpeed(speed); + Log.d(TAG, "Playback speed was set to " + speed); + callback.playbackSpeedChanged(speed); + } + } + playerLock.unlock(); + } + + /** + * Sets the playback speed. + * This method is executed on an internal executor service. + */ + @Override + public void setSpeed(final float speed) { + executor.submit(() -> setSpeedSync(speed)); + } + + /** + * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned. + */ + @Override + public float getPlaybackSpeed() { + if (!playerLock.tryLock()) { + return 1; + } + + float retVal = 1; + if ((playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) && mediaPlayer.canSetSpeed()) { + retVal = mediaPlayer.getCurrentSpeedMultiplier(); + } + playerLock.unlock(); + return retVal; + } + + /** + * Sets the playback volume. + * This method is executed on an internal executor service. + */ + @Override + public void setVolume(final float volumeLeft, float volumeRight) { + executor.submit(() -> setVolumeSync(volumeLeft, volumeRight)); + } + + /** + * Sets the playback volume. + * This method is executed on the caller's thread. + */ + private void setVolumeSync(float volumeLeft, float volumeRight) { + playerLock.lock(); + if (media != null && media.getMediaType() == MediaType.AUDIO) { + mediaPlayer.setVolume(volumeLeft, volumeRight); + Log.d(TAG, "Media player volume was set to " + volumeLeft + " " + volumeRight); + } + playerLock.unlock(); + } + + /** + * Returns true if the mediaplayer can mix stereo down to mono + */ + @Override + public boolean canDownmix() { + boolean retVal = false; + if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) { + retVal = mediaPlayer.canDownmix(); + } + return retVal; + } + + @Override + public void setDownmix(boolean enable) { + playerLock.lock(); + if (media != null && media.getMediaType() == MediaType.AUDIO) { + mediaPlayer.setDownmix(enable); + Log.d(TAG, "Media player downmix was set to " + enable); + } + playerLock.unlock(); + } + + @Override + public MediaType getCurrentMediaType() { + return mediaType; + } + + @Override + public boolean isStreaming() { + return stream; + } + + + /** + * Releases internally used resources. This method should only be called when the object is not used anymore. + */ + @Override + public void shutdown() { + executor.shutdown(); + if (mediaPlayer != null) { + mediaPlayer.release(); + } + releaseWifiLockIfNecessary(); + } + + /** + * 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. + */ + @Override + public void shutdownQuietly() { + executor.submit(this::shutdown); + executor.shutdown(); + } + + @Override + public void setVideoSurface(final SurfaceHolder surface) { + executor.submit(() -> { + playerLock.lock(); + if (mediaPlayer != null) { + mediaPlayer.setDisplay(surface); + } + playerLock.unlock(); + }); + } + + @Override + public void resetVideoSurface() { + executor.submit(() -> { + playerLock.lock(); + Log.d(TAG, "Resetting video surface"); + mediaPlayer.setDisplay(null); + reinit(); + playerLock.unlock(); + }); + } + + /** + * 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. + */ + @Override + public Pair<Integer, Integer> getVideoSize() { + if (!playerLock.tryLock()) { + // use cached value if lock can't be aquired + return videoSize; + } + Pair<Integer, Integer> res; + if (mediaPlayer == null || playerStatus == PlayerStatus.ERROR || mediaType != MediaType.VIDEO) { + res = null; + } else { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + videoSize = new Pair<>(vp.getVideoWidth(), vp.getVideoHeight()); + res = videoSize; + } + playerLock.unlock(); + return res; + } + + /** + * 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 + */ + @Override + public Playable getPlayable() { + return media; + } + + @Override + protected void setPlayable(Playable playable) { + media = playable; + } + + private IPlayer createMediaPlayer() { + if (mediaPlayer != null) { + mediaPlayer.release(); + } + if (media == null || media.getMediaType() == MediaType.VIDEO) { + mediaPlayer = new VideoPlayer(); + } else { + mediaPlayer = new AudioPlayer(context); + } + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); + return setMediaPlayerListeners(mediaPlayer); + } + + private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() { + + @Override + public void onAudioFocusChange(final int focusChange) { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + + // If there is an incoming call, playback should be paused permanently + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final int callState = (tm != null) ? tm.getCallState() : 0; + Log.i(TAG, "Call state:" + callState); + + if (focusChange == AudioManager.AUDIOFOCUS_LOSS || + (!UserPreferences.shouldResumeAfterCall() && callState != TelephonyManager.CALL_STATE_IDLE)) { + Log.d(TAG, "Lost audio focus"); + pause(true, false); + callback.shouldStop(); + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + Log.d(TAG, "Gained audio focus"); + if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now + resume(); + } else { // we ducked => raise audio level back + setVolumeSync(UserPreferences.getLeftVolume(), + UserPreferences.getRightVolume()); + } + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + if (playerStatus == PlayerStatus.PLAYING) { + if (!UserPreferences.shouldPauseForFocusLoss()) { + Log.d(TAG, "Lost audio focus temporarily. Ducking..."); + final float DUCK_FACTOR = 0.25f; + setVolumeSync(DUCK_FACTOR * UserPreferences.getLeftVolume(), + DUCK_FACTOR * UserPreferences.getRightVolume()); + pausedBecauseOfTransientAudiofocusLoss = false; + } else { + Log.d(TAG, "Lost audio focus temporarily. Could duck, but won't, pausing..."); + pause(false, false); + pausedBecauseOfTransientAudiofocusLoss = true; + } + } + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + if (playerStatus == PlayerStatus.PLAYING) { + Log.d(TAG, "Lost audio focus temporarily. Pausing..."); + pause(false, false); + pausedBecauseOfTransientAudiofocusLoss = true; + } + } + playerLock.unlock(); + } + }); + } + }; + + + @Override + public void endPlayback(final boolean wasSkipped, boolean switchingPlayers) { + executor.submit(() -> { + playerLock.lock(); + releaseWifiLockIfNecessary(); + + boolean isPlaying = playerStatus == PlayerStatus.PLAYING; + + if (playerStatus != PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.INDETERMINATE, media); + } + if (mediaPlayer != null) { + mediaPlayer.reset(); + + } + audioManager.abandonAudioFocus(audioFocusChangeListener); + callback.endPlayback(media, isPlaying, wasSkipped, switchingPlayers); + + playerLock.unlock(); + }); + } + + /** + * Moves the LocalPSMP into STOPPED state. This call is only valid if the player is currently in + * INDETERMINATE state, for example after a call to endPlayback. + * This method will only take care of changing the PlayerStatus of this object! Other tasks like + * abandoning audio focus have to be done with other methods. + */ + @Override + public void stop() { + executor.submit(() -> { + playerLock.lock(); + releaseWifiLockIfNecessary(); + + if (playerStatus == PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.STOPPED, null); + } else { + Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); + } + playerLock.unlock(); + + }); + } + + @Override + protected boolean shouldLockWifi(){ + return stream; + } + + private IPlayer setMediaPlayerListeners(IPlayer mp) { + if (mp != null && media != null) { + if (media.getMediaType() == MediaType.AUDIO) { + ((AudioPlayer) mp) + .setOnCompletionListener(audioCompletionListener); + ((AudioPlayer) mp) + .setOnSeekCompleteListener(audioSeekCompleteListener); + ((AudioPlayer) mp).setOnErrorListener(audioErrorListener); + ((AudioPlayer) mp) + .setOnBufferingUpdateListener(audioBufferingUpdateListener); + ((AudioPlayer) mp).setOnInfoListener(audioInfoListener); + ((AudioPlayer) mp).setOnSpeedAdjustmentAvailableChangedListener(audioSetSpeedAbilityListener); + } else { + ((VideoPlayer) mp) + .setOnCompletionListener(videoCompletionListener); + ((VideoPlayer) mp) + .setOnSeekCompleteListener(videoSeekCompleteListener); + ((VideoPlayer) mp).setOnErrorListener(videoErrorListener); + ((VideoPlayer) mp) + .setOnBufferingUpdateListener(videoBufferingUpdateListener); + ((VideoPlayer) mp).setOnInfoListener(videoInfoListener); + } + } + return mp; + } + + private final MediaPlayer.OnCompletionListener audioCompletionListener = + mp -> genericOnCompletion(); + + private final android.media.MediaPlayer.OnCompletionListener videoCompletionListener = + mp -> genericOnCompletion(); + + private void genericOnCompletion() { + endPlayback(false, false); + } + + private final MediaPlayer.OnBufferingUpdateListener audioBufferingUpdateListener = + (mp, percent) -> genericOnBufferingUpdate(percent); + + private final android.media.MediaPlayer.OnBufferingUpdateListener videoBufferingUpdateListener = + (mp, percent) -> genericOnBufferingUpdate(percent); + + private void genericOnBufferingUpdate(int percent) { + callback.onBufferingUpdate(percent); + } + + private final MediaPlayer.OnInfoListener audioInfoListener = + (mp, what, extra) -> genericInfoListener(what); + + private final android.media.MediaPlayer.OnInfoListener videoInfoListener = + (mp, what, extra) -> genericInfoListener(what); + + private boolean genericInfoListener(int what) { + return callback.onMediaPlayerInfo(what, 0); + } + + private final MediaPlayer.OnSpeedAdjustmentAvailableChangedListener audioSetSpeedAbilityListener = + (arg0, speedAdjustmentAvailable) -> callback.setSpeedAbilityChanged(); + + + private final MediaPlayer.OnErrorListener audioErrorListener = + (mp, what, extra) -> { + if(mp.canFallback()) { + mp.fallback(); + return true; + } else { + return genericOnError(mp, what, extra); + } + }; + + private final android.media.MediaPlayer.OnErrorListener videoErrorListener = this::genericOnError; + + private boolean genericOnError(Object inObj, int what, int extra) { + return callback.onMediaPlayerError(inObj, what, extra); + } + + private final MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener = + mp -> genericSeekCompleteListener(); + + private final android.media.MediaPlayer.OnSeekCompleteListener videoSeekCompleteListener = + mp -> genericSeekCompleteListener(); + + private void genericSeekCompleteListener() { + Thread t = new Thread(() -> { + Log.d(TAG, "genericSeekCompleteListener"); + if(seekLatch != null) { + seekLatch.countDown(); + } + playerLock.lock(); + if (playerStatus == PlayerStatus.SEEKING) { + setPlayerStatus(statusBeforeSeeking, media); + } + playerLock.unlock(); + }); + t.start(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index 6ab9859ac..2e4a486e6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -15,15 +15,20 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.AudioManager; import android.media.MediaPlayer; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; import android.os.Binder; import android.os.Build; import android.os.IBinder; import android.os.Vibrator; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.support.v7.app.NotificationCompat; +import android.support.v7.media.MediaRouter; import android.text.TextUtils; import android.util.Log; import android.util.Pair; @@ -35,11 +40,17 @@ import android.view.WindowManager; import android.widget.Toast; import com.bumptech.glide.Glide; +import com.bumptech.glide.request.target.Target; +import com.google.android.gms.cast.ApplicationMetadata; +import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager; import java.util.List; import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.cast.CastConsumer; +import de.danoeh.antennapod.core.cast.CastManager; +import de.danoeh.antennapod.core.cast.DefaultCastConsumer; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; @@ -54,14 +65,16 @@ import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.util.IntList; +import de.danoeh.antennapod.core.util.NetworkUtils; import de.danoeh.antennapod.core.util.QueueAccess; import de.danoeh.antennapod.core.util.flattr.FlattrUtils; +import de.danoeh.antennapod.core.util.playback.ExternalMedia; import de.danoeh.antennapod.core.util.playback.Playable; /** * Controls the MediaPlayer that plays a FeedMedia-file */ -public class PlaybackService extends Service implements SharedPreferences.OnSharedPreferenceChangeListener { +public class PlaybackService extends Service { public static final String FORCE_WIDGET_UPDATE = "de.danoeh.antennapod.FORCE_WIDGET_UPDATE"; public static final String STOP_WIDGET_UPDATE = "de.danoeh.antennapod.STOP_WIDGET_UPDATE"; /** @@ -74,6 +87,10 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar */ public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra"; /** + * True if cast session should disconnect. + */ + public static final String EXTRA_CAST_DISCONNECT = "extra.de.danoeh.antennapod.core.service.castDisconnect"; + /** * True if media should be streamed. */ public static final String EXTRA_SHOULD_STREAM = "extra.de.danoeh.antennapod.core.service.shouldStream"; @@ -123,6 +140,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar */ public static final int EXTRA_CODE_AUDIO = 1; public static final int EXTRA_CODE_VIDEO = 2; + public static final int EXTRA_CODE_CAST = 3; public static final int NOTIFICATION_TYPE_ERROR = 0; public static final int NOTIFICATION_TYPE_INFO = 1; @@ -154,12 +172,23 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar public static final int NOTIFICATION_TYPE_SET_SPEED_ABILITY_CHANGED = 9; /** + * Send a message to the user (with provided String resource id) + */ + public static final int NOTIFICATION_TYPE_SHOW_TOAST = 10; + + /** * Returned by getPositionSafe() or getDurationSafe() if the playbackService * is in an invalid state. */ public static final int INVALID_TIME = -1; /** + * Time in seconds during which the CastManager will try to reconnect to the Cast Device after + * the Wifi Connection is regained. + */ + private static final int RECONNECTION_ATTEMPT_PERIOD_S = 15; + + /** * Is true if service is running. */ public static boolean isRunning = false; @@ -171,11 +200,25 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar * Is true if the service was running, but paused due to headphone disconnect */ public static boolean transientPause = false; + /** + * Is true if a Cast Device is connected to the service. + */ + private static volatile boolean isCasting = false; + /** + * Stores the state of the cast playback just before it disconnects. + */ + private volatile PlaybackServiceMediaPlayer.PSMPInfo infoBeforeCastDisconnection; + + private boolean wifiConnectivity = true; + private BroadcastReceiver wifiBroadcastReceiver; private static final int NOTIFICATION_ID = 1; private PlaybackServiceMediaPlayer mediaPlayer; private PlaybackServiceTaskManager taskManager; + + private CastManager castManager; + private MediaRouter mediaRouter; /** * Only used for Lollipop notifications. */ @@ -206,12 +249,12 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar */ public static Intent getPlayerActivityIntent(Context context) { if (isRunning) { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType, isCasting); } else { if (PlaybackPreferences.getCurrentEpisodeIsVideo()) { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO, isCasting); } else { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO, isCasting); } } } @@ -222,7 +265,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar */ public static Intent getPlayerActivityIntent(Context context, Playable media) { MediaType mt = media.getMediaType(); - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt, isCasting); } @Override @@ -248,7 +291,10 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar registerReceiver(pauseResumeCurrentEpisodeReceiver, new IntentFilter( ACTION_RESUME_PLAY_CURRENT_EPISODE)); taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback); - mediaPlayer = new PlaybackServiceMediaPlayer(this, mediaPlayerCallback); + + mediaRouter = MediaRouter.getInstance(getApplicationContext()); + PreferenceManager.getDefaultSharedPreferences(this) + .registerOnSharedPreferenceChangeListener(prefListener); ComponentName eventReceiver = new ComponentName(getApplicationContext(), MediaButtonReceiver.class); @@ -261,7 +307,6 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar try { mediaSession.setCallback(sessionCallback); mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); - mediaSession.setActive(true); } catch (NullPointerException npe) { // on some devices (Huawei) setting active can cause a NullPointerException // even with correct use of the api. @@ -270,8 +315,21 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar Log.e(TAG, "NullPointerException while setting up MediaSession"); npe.printStackTrace(); } - PreferenceManager.getDefaultSharedPreferences(this) - .registerOnSharedPreferenceChangeListener(this); + + castManager = CastManager.getInstance(); + castManager.addCastConsumer(castConsumer); + isCasting = castManager.isConnected(); + if (isCasting) { + if (UserPreferences.isCastEnabled()) { + onCastAppConnected(false); + } else { + castManager.disconnect(); + } + } else { + mediaPlayer = new LocalPSMP(this, mediaPlayerCallback); + } + + mediaSession.setActive(true); } @Override @@ -283,7 +341,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar currentMediaType = MediaType.UNKNOWN; PreferenceManager.getDefaultSharedPreferences(this) - .unregisterOnSharedPreferenceChangeListener(this); + .unregisterOnSharedPreferenceChangeListener(prefListener); if (mediaSession != null) { mediaSession.release(); } @@ -296,6 +354,8 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar unregisterReceiver(skipCurrentEpisodeReceiver); unregisterReceiver(pausePlayCurrentEpisodeReceiver); unregisterReceiver(pauseResumeCurrentEpisodeReceiver); + castManager.removeCastConsumer(castConsumer); + unregisterWifiBroadcastReceiver(); mediaPlayer.shutdown(); taskManager.shutdown(); } @@ -307,22 +367,17 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if(key.equals(UserPreferences.PREF_LOCKSCREEN_BACKGROUND)) { - updateMediaSessionMetadata(getPlayable()); - } - } - - @Override public int onStartCommand(Intent intent, int flags, int startId) { super.onStartCommand(intent, flags, startId); Log.d(TAG, "OnStartCommand called"); final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1); + final boolean castDisconnect = intent.getBooleanExtra(EXTRA_CAST_DISCONNECT, false); final Playable playable = intent.getParcelableExtra(EXTRA_PLAYABLE); - if (keycode == -1 && playable == null) { + if (keycode == -1 && playable == null && !castDisconnect) { Log.e(TAG, "PlaybackService was started with no arguments"); stopSelf(); + return Service.START_REDELIVER_INTENT; } if ((flags & Service.START_FLAG_REDELIVERY) != 0) { @@ -334,6 +389,8 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar Log.d(TAG, "Received media button event"); handleKeycode(keycode, intent.getIntExtra(MediaButtonReceiver.EXTRA_SOURCE, InputDevice.SOURCE_CLASS_NONE)); + } else if (castDisconnect) { + castManager.disconnect(); } else { started = true; boolean stream = intent.getBooleanExtra(EXTRA_SHOULD_STREAM, @@ -341,6 +398,10 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar boolean startWhenPrepared = intent.getBooleanExtra(EXTRA_START_WHEN_PREPARED, false); boolean prepareImmediately = intent.getBooleanExtra(EXTRA_PREPARE_IMMEDIATELY, false); sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + //If the user asks to play External Media, the casting session, if on, should end. + if (playable instanceof ExternalMedia) { + castManager.disconnect(); + } mediaPlayer.playMediaObject(playable, stream, startWhenPrepared, prepareImmediately); } } @@ -359,11 +420,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar case KeyEvent.KEYCODE_HEADSETHOOK: case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: if (status == PlayerStatus.PLAYING) { - if (UserPreferences.isPersistNotify()) { - mediaPlayer.pause(false, true); - } else { - mediaPlayer.pause(true, true); - } + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); } else if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { mediaPlayer.resume(); } else if (status == PlayerStatus.PREPARING) { @@ -383,12 +440,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar break; case KeyEvent.KEYCODE_MEDIA_PAUSE: if (status == PlayerStatus.PLAYING) { - mediaPlayer.pause(false, true); - } - if (UserPreferences.isPersistNotify()) { - mediaPlayer.pause(false, true); - } else { - mediaPlayer.pause(true, true); + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); } break; @@ -397,7 +449,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar UserPreferences.shouldHardwareButtonSkip()) { // assume the skip command comes from a notification or the lockscreen // a >| skip button should actually skip - mediaPlayer.endPlayback(true); + mediaPlayer.endPlayback(true, false); } else { // assume skip command comes from a (bluetooth) media button // user actually wants to fast-forward @@ -509,12 +561,13 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar taskManager.cancelPositionSaver(); saveCurrentPosition(false, 0); taskManager.cancelWidgetUpdater(); - if (UserPreferences.isPersistNotify() && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + if ((UserPreferences.isPersistNotify() || isCasting) && + android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { // do not remove notification on pause based on user pref and whether android version supports expanded notifications // Change [Play] button to [Pause] setupNotification(newInfo); - } else if (!UserPreferences.isPersistNotify()) { - // remove notifcation on pause + } else if (!UserPreferences.isPersistNotify() && !isCasting) { + // remove notification on pause stopForeground(true); } writePlayerStatusPlaybackPreferences(); @@ -587,12 +640,14 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } @Override - public void updateMediaSessionMetadata(Playable p) { - PlaybackService.this.updateMediaSessionMetadata(p); + public void reloadUI() { + Log.d(TAG, "reloadUI callback reached"); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + PlaybackService.this.updateMediaSessionMetadata(getPlayable()); } @Override - public boolean onMediaPlayerInfo(int code) { + public boolean onMediaPlayerInfo(int code, @StringRes int resourceId) { switch (code) { case MediaPlayer.MEDIA_INFO_BUFFERING_START: sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_START, 0); @@ -600,6 +655,12 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar case MediaPlayer.MEDIA_INFO_BUFFERING_END: sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_END, 0); return true; + case RemotePSMP.CAST_ERROR: + sendNotificationBroadcast(NOTIFICATION_TYPE_SHOW_TOAST, resourceId); + return true; + case RemotePSMP.CAST_ERROR_PRIORITY_HIGH: + Toast.makeText(PlaybackService.this, resourceId, Toast.LENGTH_SHORT).show(); + return true; default: return false; } @@ -619,16 +680,15 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } @Override - public boolean endPlayback(boolean playNextEpisode, boolean wasSkipped) { - PlaybackService.this.endPlayback(playNextEpisode, wasSkipped); + public boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { + PlaybackService.this.endPlayback(media, playNextEpisode, wasSkipped, switchingPlayers); return true; } }; - private void endPlayback(boolean playNextEpisode, boolean wasSkipped) { - Log.d(TAG, "Playback ended"); + private void endPlayback(final Playable playable, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { + Log.d(TAG, "Playback ended" + (switchingPlayers ? " from switching players": "")); - final Playable playable = mediaPlayer.getPlayable(); if (playable == null) { Log.e(TAG, "Cannot end playback: media was null"); return; @@ -643,26 +703,35 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar FeedMedia media = (FeedMedia) playable; FeedItem item = media.getItem(); - try { - final List<FeedItem> queue = taskManager.getQueue(); - isInQueue = QueueAccess.ItemListAccess(queue).contains(item.getId()); - nextItem = DBTasks.getQueueSuccessorOfItem(item.getId(), queue); - } catch (InterruptedException e) { - e.printStackTrace(); - // isInQueue remains false - } + if (!switchingPlayers) { + try { + final List<FeedItem> queue = taskManager.getQueue(); + isInQueue = QueueAccess.ItemListAccess(queue).contains(item.getId()); + nextItem = DBTasks.getQueueSuccessorOfItem(item.getId(), queue); + } catch (InterruptedException e) { + e.printStackTrace(); + // isInQueue remains false + } - boolean shouldKeep = wasSkipped && UserPreferences.shouldSkipKeepEpisode(); + boolean shouldKeep = wasSkipped && UserPreferences.shouldSkipKeepEpisode(); - if (!shouldKeep) { - // only mark the item as played if we're not keeping it anyways - DBWriter.markItemPlayed(item, FeedItem.PLAYED, true); + if (!shouldKeep) { + // only mark the item as played if we're not keeping it anyways + DBWriter.markItemPlayed(item, FeedItem.PLAYED, true); - if (isInQueue) { - DBWriter.removeQueueItem(PlaybackService.this, item, true); + if (isInQueue) { + DBWriter.removeQueueItem(PlaybackService.this, item, true); + } + + // Delete episode if enabled + if (item.getFeed().getPreferences().getCurrentAutoDelete()) { + DBWriter.deleteFeedMediaOfItem(PlaybackService.this, media.getId()); + Log.d(TAG, "Episode Deleted"); + } } } + DBWriter.addItemToPlaybackHistory(media); // auto-flattr if enabled @@ -670,12 +739,6 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar DBTasks.flattrItemIfLoggedIn(PlaybackService.this, item); } - // Delete episode if enabled - if(item.getFeed().getPreferences().getCurrentAutoDelete() && !shouldKeep ) { - DBWriter.deleteFeedMediaOfItem(PlaybackService.this, media.getId()); - Log.d(TAG, "Episode Deleted"); - } - // gpodder play action if(GpodnetPreferences.loggedIn()) { GpodnetEpisodeAction action = new GpodnetEpisodeAction.Builder(item, Action.PLAY) @@ -689,46 +752,49 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } } - // Load next episode if previous episode was in the queue and if there - // is an episode in the queue left. - // Start playback immediately if continuous playback is enabled - Playable nextMedia = null; - boolean loadNextItem = ClientConfig.playbackServiceCallbacks.useQueue() && - isInQueue && - nextItem != null; - - playNextEpisode = playNextEpisode && - loadNextItem && - UserPreferences.isFollowQueue(); - - if (loadNextItem) { - Log.d(TAG, "Loading next item in queue"); - nextMedia = nextItem.getMedia(); - } - final boolean prepareImmediately; - final boolean startWhenPrepared; - final boolean stream; + if (!switchingPlayers) { + // Load next episode if previous episode was in the queue and if there + // is an episode in the queue left. + // Start playback immediately if continuous playback is enabled + Playable nextMedia = null; + boolean loadNextItem = ClientConfig.playbackServiceCallbacks.useQueue() && + isInQueue && + nextItem != null; + + playNextEpisode = playNextEpisode && + loadNextItem && + UserPreferences.isFollowQueue(); + + if (loadNextItem) { + Log.d(TAG, "Loading next item in queue"); + nextMedia = nextItem.getMedia(); + } + final boolean prepareImmediately; + final boolean startWhenPrepared; + final boolean stream; - if (playNextEpisode) { - Log.d(TAG, "Playback of next episode will start immediately."); - prepareImmediately = startWhenPrepared = true; - } else { - Log.d(TAG, "No more episodes available to play"); - prepareImmediately = startWhenPrepared = false; - stopForeground(true); - stopWidgetUpdater(); - } + if (playNextEpisode) { + Log.d(TAG, "Playback of next episode will start immediately."); + prepareImmediately = startWhenPrepared = true; + } else { + Log.d(TAG, "No more episodes available to play"); + prepareImmediately = startWhenPrepared = false; + stopForeground(true); + stopWidgetUpdater(); + } - writePlaybackPreferencesNoMediaPlaying(); - if (nextMedia != null) { - stream = !nextMedia.localFileAvailable(); - mediaPlayer.playMediaObject(nextMedia, stream, startWhenPrepared, prepareImmediately); - sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, - (nextMedia.getMediaType() == MediaType.VIDEO) ? EXTRA_CODE_VIDEO : EXTRA_CODE_AUDIO); - } else { - sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); - mediaPlayer.stop(); - //stopSelf(); + writePlaybackPreferencesNoMediaPlaying(); + if (nextMedia != null) { + stream = !nextMedia.localFileAvailable(); + mediaPlayer.playMediaObject(nextMedia, stream, startWhenPrepared, prepareImmediately); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, + isCasting ? EXTRA_CODE_CAST : + (nextMedia.getMediaType() == MediaType.VIDEO) ? EXTRA_CODE_VIDEO : EXTRA_CODE_AUDIO); + } else { + sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); + mediaPlayer.stop(); + //stopSelf(); + } } } @@ -853,11 +919,6 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } /** - * Used by setupNotification to load notification data in another thread. - */ - private Thread notificationSetupThread; - - /** * Updates the Media Session for the corresponding status. * @param playerStatus the current {@link PlayerStatus} */ @@ -906,38 +967,69 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar mediaSession.setPlaybackState(sessionState.build()); } - private void updateMediaSessionMetadata(Playable p) { - if (p == null) { + /** + * Used by updateMediaSessionMetadata to load notification data in another thread. + */ + private Thread mediaSessionSetupThread; + + private void updateMediaSessionMetadata(final Playable p) { + if (p == null || mediaSession == null) { return; } - MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder(); - builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, p.getFeedTitle()); - builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, p.getEpisodeTitle()); - builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, p.getDuration()); - builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle()); - builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle()); - - if (p.getImageUri() != null && UserPreferences.setLockscreenBackground()) { - builder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, p.getImageUri().toString()); - try { - WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); - Display display = wm.getDefaultDisplay(); - Bitmap art = Glide.with(this) - .load(p.getImageUri()) - .asBitmap() - .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) - .centerCrop() - .into(display.getWidth(), display.getHeight()) - .get(); - builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); - } catch (Throwable tr) { - Log.e(TAG, Log.getStackTraceString(tr)); - } + if (mediaSessionSetupThread != null) { + mediaSessionSetupThread.interrupt(); } - mediaSession.setMetadata(builder.build()); + + Runnable mediaSessionSetupTask = () -> { + MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder(); + builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, p.getFeedTitle()); + builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, p.getEpisodeTitle()); + builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, p.getDuration()); + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle()); + builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle()); + + if (p.getImageUri() != null && UserPreferences.setLockscreenBackground()) { + builder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, p.getImageUri().toString()); + try { + if (isCasting) { + Bitmap art = Glide.with(this) + .load(p.getImageUri()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .into(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get(); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); + } else { + WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Bitmap art = Glide.with(this) + .load(p.getImageUri()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .centerCrop() + .into(display.getWidth(), display.getHeight()) + .get(); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); + } + } catch (Throwable tr) { + Log.e(TAG, Log.getStackTraceString(tr)); + } + } + if (!Thread.currentThread().isInterrupted() && started) { + mediaSession.setMetadata(builder.build()); + } + }; + + mediaSessionSetupThread = new Thread(mediaSessionSetupTask); + mediaSessionSetupThread.start(); } /** + * Used by setupNotification to load notification data in another thread. + */ + private Thread notificationSetupThread; + + /** * Prepares notification and starts the service in the foreground. */ private void setupNotification(final PlaybackServiceMediaPlayer.PSMPInfo info) { @@ -966,8 +1058,8 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar .centerCrop() .into(iconSize, iconSize) .get(); - } catch(Throwable tr) { - Log.e(TAG, Log.getStackTraceString(tr)); + } catch (Throwable tr) { + Log.e(TAG, "Error loading the media icon for the notification", tr); } } } @@ -1002,6 +1094,17 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar int numActions = 0; // we start and 0 and then increment by 1 for each call to addAction + if (isCasting) { + Intent stopCastingIntent = new Intent(PlaybackService.this, PlaybackService.class); + stopCastingIntent.putExtra(EXTRA_CAST_DISCONNECT, true); + PendingIntent stopCastingPendingIntent = PendingIntent.getService(PlaybackService.this, + numActions, stopCastingIntent, PendingIntent.FLAG_UPDATE_CURRENT); + notificationBuilder.addAction(R.drawable.ic_media_cast_disconnect, + getString(R.string.cast_disconnect_label), + stopCastingPendingIntent); + numActions++; + } + // always let them rewind PendingIntent rewindButtonPendingIntent = getPendingIntentForMediaAction( KeyEvent.KEYCODE_MEDIA_REWIND, numActions); @@ -1066,7 +1169,8 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar if (playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARING || - playerStatus == PlayerStatus.SEEKING) { + playerStatus == PlayerStatus.SEEKING || + isCasting) { startForeground(NOTIFICATION_ID, notification); } else { stopForeground(false); @@ -1118,11 +1222,10 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar DBTasks.flattrItemIfLoggedIn(this, item); } } - playable.saveCurrentPosition(PreferenceManager - .getDefaultSharedPreferences(getApplicationContext()), + playable.saveCurrentPosition( + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()), position, - System.currentTimeMillis() - ); + System.currentTimeMillis()); } } @@ -1231,11 +1334,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { transientPause = true; } - if (UserPreferences.isPersistNotify()) { - mediaPlayer.pause(false, true); - } else { - mediaPlayer.pause(true, true); - } + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); } } @@ -1274,7 +1373,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar public void onReceive(Context context, Intent intent) { if (TextUtils.equals(intent.getAction(), ACTION_SKIP_CURRENT_EPISODE)) { Log.d(TAG, "Received SKIP_CURRENT_EPISODE intent"); - mediaPlayer.endPlayback(true); + mediaPlayer.endPlayback(true, false); } } }; @@ -1303,6 +1402,10 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar return currentMediaType; } + public static boolean isCasting() { + return isCasting; + } + public void resume() { mediaPlayer.resume(); } @@ -1391,7 +1494,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } /** - * @see de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer#seekToChapter(de.danoeh.antennapod.core.feed.Chapter) + * @see LocalPSMP#seekToChapter(de.danoeh.antennapod.core.feed.Chapter) */ public void seekToChapter(Chapter c) { mediaPlayer.seekToChapter(c); @@ -1430,6 +1533,67 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar } } + private CastConsumer castConsumer = new DefaultCastConsumer() { + @Override + public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { + PlaybackService.this.onCastAppConnected(wasLaunched); + } + + @Override + public void onDisconnectionReason(int reason) { + Log.d(TAG, "onDisconnectionReason() with code " + reason); + // This is our final chance to update the underlying stream position + // In onDisconnected(), the underlying CastPlayback#mVideoCastConsumer + // is disconnected and hence we update our local value of stream position + // to the latest position. + if (mediaPlayer != null) { + saveCurrentPosition(false, 0); + infoBeforeCastDisconnection = mediaPlayer.getPSMPInfo(); + if (reason != BaseCastManager.DISCONNECT_REASON_EXPLICIT && + infoBeforeCastDisconnection.playerStatus == PlayerStatus.PLAYING) { + // If it's NOT based on user action, we shouldn't automatically resume local playback + infoBeforeCastDisconnection.playerStatus = PlayerStatus.PAUSED; + } + } + } + + @Override + public void onDisconnected() { + Log.d(TAG, "onDisconnected()"); + isCasting = false; + PlaybackServiceMediaPlayer.PSMPInfo info = infoBeforeCastDisconnection; + infoBeforeCastDisconnection = null; + if (info == null && mediaPlayer != null) { + info = mediaPlayer.getPSMPInfo(); + } + if (info == null) { + info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.STOPPED, null); + } + switchMediaPlayer(new LocalPSMP(PlaybackService.this, mediaPlayerCallback), + info, true); + if (info.playable != null) { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, + info.playable.getMediaType() == MediaType.AUDIO ? EXTRA_CODE_AUDIO : EXTRA_CODE_VIDEO); + } else { + Log.d(TAG, "Cast session disconnected, but no current media"); + sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); + } + // hardware volume buttons control the local device volume + mediaRouter.setMediaSessionCompat(null); + unregisterWifiBroadcastReceiver(); + PlayerStatus status = info.playerStatus; + if ((status == PlayerStatus.PLAYING || + status == PlayerStatus.SEEKING || + status == PlayerStatus.PREPARING || + UserPreferences.isPersistNotify()) && + android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + setupNotification(info); + } else if (!UserPreferences.isPersistNotify()){ + stopForeground(true); + } + } + }; + private final MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback() { private static final String TAG = "MediaSessionCompat"; @@ -1487,7 +1651,7 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar public void onSkipToNext() { Log.d(TAG, "onSkipToNext()"); if(UserPreferences.shouldHardwareButtonSkip()) { - mediaPlayer.endPlayback(true); + mediaPlayer.endPlayback(true, false); } else { seekDelta(UserPreferences.getFastFowardSecs() * 1000); } @@ -1514,4 +1678,101 @@ public class PlaybackService extends Service implements SharedPreferences.OnShar return false; } }; + + private void onCastAppConnected(boolean wasLaunched) { + Log.d(TAG, "A cast device application was " + (wasLaunched ? "launched" : "joined")); + isCasting = true; + PlaybackServiceMediaPlayer.PSMPInfo info = null; + if (mediaPlayer != null) { + info = mediaPlayer.getPSMPInfo(); + if (info.playerStatus == PlayerStatus.PLAYING) { + // could be pause, but this way we make sure the new player will get the correct position, + // since pause runs asynchronously and we could be directing the new player to play even before + // the old player gives us back the position. + saveCurrentPosition(false, 0); + } + } + if (info == null) { + info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.STOPPED, null); + } + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, EXTRA_CODE_CAST); + switchMediaPlayer(new RemotePSMP(PlaybackService.this, mediaPlayerCallback), + info, + wasLaunched); + // hardware volume buttons control the remote device volume + mediaRouter.setMediaSessionCompat(mediaSession); + registerWifiBroadcastReceiver(); + setupNotification(info); + } + + private void switchMediaPlayer(@NonNull PlaybackServiceMediaPlayer newPlayer, + @NonNull PlaybackServiceMediaPlayer.PSMPInfo info, + boolean wasLaunched) { + if (mediaPlayer != null) { + mediaPlayer.endPlayback(true, true); + mediaPlayer.shutdownQuietly(); + } + mediaPlayer = newPlayer; + Log.d(TAG, "switched to " + mediaPlayer.getClass().getSimpleName()); + if (!wasLaunched) { + PlaybackServiceMediaPlayer.PSMPInfo candidate = mediaPlayer.getPSMPInfo(); + if (candidate.playable != null && + candidate.playerStatus.isAtLeast(PlayerStatus.PREPARING)) { + // do not automatically send new media to cast device + info.playable = null; + } + } + if (info.playable != null) { + mediaPlayer.playMediaObject(info.playable, + !info.playable.localFileAvailable(), + info.playerStatus == PlayerStatus.PLAYING, + info.playerStatus.isAtLeast(PlayerStatus.PREPARING)); + } + } + + private void registerWifiBroadcastReceiver() { + if (wifiBroadcastReceiver != null) { + return; + } + wifiBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(WifiManager.NETWORK_STATE_CHANGED_ACTION)) { + NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); + boolean isConnected = info.isConnected(); + //apparently this method gets called twice when a change happens, but one run is enough. + if (isConnected && !wifiConnectivity) { + wifiConnectivity = true; + castManager.startCastDiscovery(); + castManager.reconnectSessionIfPossible(RECONNECTION_ATTEMPT_PERIOD_S, NetworkUtils.getWifiSsid()); + } else { + wifiConnectivity = isConnected; + } + } + } + }; + registerReceiver(wifiBroadcastReceiver, + new IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION)); + } + + private void unregisterWifiBroadcastReceiver() { + if (wifiBroadcastReceiver != null) { + unregisterReceiver(wifiBroadcastReceiver); + wifiBroadcastReceiver = null; + } + } + + private SharedPreferences.OnSharedPreferenceChangeListener prefListener = + (sharedPreferences, key) -> { + if (UserPreferences.PREF_CAST_ENABLED.equals(key)) { + if (!UserPreferences.isCastEnabled()) { + if (castManager.isConnecting() || castManager.isConnected()) { + Log.d(TAG, "Disconnecting cast device due to a change in user preferences"); + castManager.disconnect(); + } + } + } else if (UserPreferences.PREF_LOCKSCREEN_BACKGROUND.equals(key)) { + updateMediaSessionMetadata(getPlayable()); + } + }; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java index d8b334295..f3a9c1d03 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java @@ -1,42 +1,31 @@ package de.danoeh.antennapod.core.service.playback; import android.content.Context; -import android.media.AudioManager; import android.net.wifi.WifiManager; -import android.os.PowerManager; import android.support.annotation.NonNull; -import android.telephony.TelephonyManager; +import android.support.annotation.StringRes; import android.util.Log; import android.util.Pair; import android.view.SurfaceHolder; -import org.antennapod.audio.MediaPlayer; - -import java.io.IOException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.RejectedExecutionHandler; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; - import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; -import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; -import de.danoeh.antennapod.core.util.playback.AudioPlayer; -import de.danoeh.antennapod.core.util.playback.IPlayer; import de.danoeh.antennapod.core.util.playback.Playable; -import de.danoeh.antennapod.core.util.playback.VideoPlayer; + +/* + * 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. + */ /** - * Manages the MediaPlayer object of the PlaybackService. + * Abstract class that allows for different implementations of the PlaybackServiceMediaPlayer for local + * and remote (cast devices) playback. */ -public class PlaybackServiceMediaPlayer { +public abstract class PlaybackServiceMediaPlayer { public static final String TAG = "PlaybackSvcMediaPlayer"; /** @@ -44,58 +33,22 @@ public class PlaybackServiceMediaPlayer { */ public static final int INVALID_TIME = -1; - private final AudioManager audioManager; - - private volatile PlayerStatus playerStatus; - private volatile PlayerStatus statusBeforeSeeking; - private volatile IPlayer mediaPlayer; - private volatile Playable media; - - private volatile boolean stream; - private volatile MediaType mediaType; - private volatile AtomicBoolean startWhenPrepared; - private volatile boolean pausedBecauseOfTransientAudiofocusLoss; - private volatile Pair<Integer, Integer> videoSize; - - /** - * Some asynchronous calls might change the state of the MediaPlayer object. Therefore calls in other threads - * have to wait until these operations have finished. - */ - private final ReentrantLock playerLock; - private CountDownLatch seekLatch; - - private final PSMPCallback callback; - private final Context context; - - private final ThreadPoolExecutor executor; + 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; + public PlaybackServiceMediaPlayer(@NonNull Context context, - @NonNull PSMPCallback callback) { + @NonNull PSMPCallback callback){ this.context = context; this.callback = callback; - this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - this.playerLock = new ReentrantLock(); - this.startWhenPrepared = new AtomicBoolean(false); - executor = new ThreadPoolExecutor(1, 1, 5, TimeUnit.MINUTES, new LinkedBlockingDeque<>(), - new RejectedExecutionHandler() { - @Override - public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { - Log.d(TAG, "Rejected execution of runnable"); - } - } - ); - mediaPlayer = null; - statusBeforeSeeking = null; - pausedBecauseOfTransientAudiofocusLoss = false; - mediaType = MediaType.UNKNOWN; playerStatus = PlayerStatus.STOPPED; - videoSize = null; } /** @@ -124,99 +77,7 @@ public class PlaybackServiceMediaPlayer { * 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 void playMediaObject(@NonNull final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { - Log.d(TAG, "playMediaObject(...)"); - executor.submit(() -> { - playerLock.lock(); - try { - playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately); - } catch (RuntimeException e) { - e.printStackTrace(); - throw e; - } finally { - playerLock.unlock(); - } - }); - } - - /** - * 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. - * <p/> - * This method requires the playerLock and is executed on the caller's thread. - * - * @see #playMediaObject(de.danoeh.antennapod.core.util.playback.Playable, boolean, boolean, boolean) - */ - private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { - if (!playerLock.isHeldByCurrentThread()) { - throw new IllegalStateException("method requires playerLock"); - } - - - 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 { - // stop playback of this episode - if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) { - mediaPlayer.stop(); - } - // set temporarily to pause in order to update list with current position - if (playerStatus == PlayerStatus.PLAYING) { - setPlayerStatus(PlayerStatus.PAUSED, media); - } - - // smart mark as played - if(media != null && media instanceof FeedMedia) { - FeedMedia oldMedia = (FeedMedia) media; - if(oldMedia.hasAlmostEnded()) { - Log.d(TAG, "smart mark as read"); - FeedItem item = oldMedia.getItem(); - DBWriter.markItemPlayed(item, FeedItem.PLAYED, false); - DBWriter.removeQueueItem(context, item, false); - DBWriter.addItemToPlaybackHistory(oldMedia); - if (item.getFeed().getPreferences().getCurrentAutoDelete()) { - Log.d(TAG, "Delete " + oldMedia.toString()); - DBWriter.deleteFeedMediaOfItem(context, oldMedia.getId()); - } - } - } - - setPlayerStatus(PlayerStatus.INDETERMINATE, null); - } - } - - this.media = playable; - this.stream = stream; - this.mediaType = media.getMediaType(); - this.videoSize = null; - createMediaPlayer(); - PlaybackServiceMediaPlayer.this.startWhenPrepared.set(startWhenPrepared); - setPlayerStatus(PlayerStatus.INITIALIZING, media); - try { - media.loadMetadata(); - executor.submit(() -> callback.updateMediaSessionMetadata(media)); - if (stream) { - mediaPlayer.setDataSource(media.getStreamUrl()); - } else { - mediaPlayer.setDataSource(media.getLocalMediaUrl()); - } - setPlayerStatus(PlayerStatus.INITIALIZED, media); - - if (prepareImmediately) { - setPlayerStatus(PlayerStatus.PREPARING, media); - mediaPlayer.prepare(); - onPrepared(startWhenPrepared); - } - - } catch (Playable.PlayableException | IOException | IllegalStateException e) { - e.printStackTrace(); - setPlayerStatus(PlayerStatus.ERROR, null); - } - } + 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. @@ -224,51 +85,7 @@ public class PlaybackServiceMediaPlayer { * <p/> * This method is executed on an internal executor service. */ - public void resume() { - executor.submit(() -> { - playerLock.lock(); - resumeSync(); - playerLock.unlock(); - }); - } - - private void resumeSync() { - if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) { - int focusGained = audioManager.requestAudioFocus( - audioFocusChangeListener, AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN); - if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - acquireWifiLockIfNecessary(); - float speed = 1.0f; - try { - speed = Float.parseFloat(UserPreferences.getPlaybackSpeed()); - } catch(NumberFormatException e) { - Log.e(TAG, Log.getStackTraceString(e)); - UserPreferences.setPlaybackSpeed(String.valueOf(speed)); - } - setSpeed(speed); - setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); - - if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { - int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( - media.getPosition(), - media.getLastPlayedTime()); - seekToSync(newPosition); - } - mediaPlayer.start(); - - setPlayerStatus(PlayerStatus.PLAYING, media); - pausedBecauseOfTransientAudiofocusLoss = false; - media.onPlaybackStart(); - - } else { - Log.e(TAG, "Failed to request audio focus"); - } - } else { - Log.d(TAG, "Call to resume() was ignored because current state of PSMP object is " + playerStatus); - } - } - + public abstract void resume(); /** * Saves the current position and pauses playback. Note that, if audiofocus @@ -280,153 +97,22 @@ public class PlaybackServiceMediaPlayer { * @param reinit is true if service should reinit after pausing if the media * file is being streamed */ - public void pause(final boolean abandonFocus, final boolean reinit) { - executor.submit(() -> { - playerLock.lock(); - releaseWifiLockIfNecessary(); - if (playerStatus == PlayerStatus.PLAYING) { - Log.d(TAG, "Pausing playback."); - mediaPlayer.pause(); - setPlayerStatus(PlayerStatus.PAUSED, media); - - if (abandonFocus) { - audioManager.abandonAudioFocus(audioFocusChangeListener); - pausedBecauseOfTransientAudiofocusLoss = false; - } - if (stream && reinit) { - reinit(); - } - } else { - Log.d(TAG, "Ignoring call to pause: Player is in " + playerStatus + " state"); - } - - playerLock.unlock(); - }); - } + public abstract void pause(boolean abandonFocus, boolean reinit); /** - * Prepares media player for playback if the service is in the INITALIZED + * Prepared media player for playback if the service is in the INITALIZED * state. * <p/> * This method is executed on an internal executor service. */ - public void prepare() { - executor.submit(() -> { - playerLock.lock(); - - if (playerStatus == PlayerStatus.INITIALIZED) { - Log.d(TAG, "Preparing media player"); - setPlayerStatus(PlayerStatus.PREPARING, media); - try { - mediaPlayer.prepare(); - onPrepared(startWhenPrepared.get()); - } catch (IOException e) { - e.printStackTrace(); - setPlayerStatus(PlayerStatus.ERROR, null); - } - } - playerLock.unlock(); - - }); - } - - /** - * Called after media player has been prepared. This method is executed on the caller's thread. - */ - void onPrepared(final boolean startWhenPrepared) { - playerLock.lock(); - - if (playerStatus != PlayerStatus.PREPARING) { - playerLock.unlock(); - throw new IllegalStateException("Player is not in PREPARING state"); - } - - Log.d(TAG, "Resource prepared"); - - if (mediaType == MediaType.VIDEO) { - VideoPlayer vp = (VideoPlayer) mediaPlayer; - videoSize = new Pair<>(vp.getVideoWidth(), vp.getVideoHeight()); - } - - if (media.getPosition() > 0) { - seekToSync(media.getPosition()); - } - - if (media.getDuration() == 0) { - Log.d(TAG, "Setting duration of media"); - media.setDuration(mediaPlayer.getDuration()); - } - setPlayerStatus(PlayerStatus.PREPARED, media); - - if (startWhenPrepared) { - resumeSync(); - } - - playerLock.unlock(); - } + 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 void reinit() { - executor.submit(() -> { - playerLock.lock(); - releaseWifiLockIfNecessary(); - if (media != null) { - playMediaObject(media, true, stream, startWhenPrepared.get(), false); - } else if (mediaPlayer != null) { - mediaPlayer.reset(); - } else { - Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null"); - } - playerLock.unlock(); - }); - } - - - /** - * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. - * - * @param t The position to seek to in milliseconds. t < 0 will be interpreted as t = 0 - * <p/> - * This method is executed on the caller's thread. - */ - private void seekToSync(int t) { - if (t < 0) { - t = 0; - } - playerLock.lock(); - - if (playerStatus == PlayerStatus.PLAYING - || playerStatus == PlayerStatus.PAUSED - || playerStatus == PlayerStatus.PREPARED) { - if (!stream) { - statusBeforeSeeking = playerStatus; - setPlayerStatus(PlayerStatus.SEEKING, media); - } - if(seekLatch != null && seekLatch.getCount() > 0) { - try { - seekLatch.await(3, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } - } - seekLatch = new CountDownLatch(1); - mediaPlayer.seekTo(t); - try { - seekLatch.await(3, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } - } else if (playerStatus == PlayerStatus.INITIALIZED) { - media.setPosition(t); - startWhenPrepared.set(false); - prepare(); - } - playerLock.unlock(); - } + public abstract void reinit(); /** * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. @@ -434,28 +120,14 @@ public class PlaybackServiceMediaPlayer { * <p/> * This method is executed on an internal executor service. */ - public void seekTo(final int t) { - executor.submit(() -> seekToSync(t)); - } + public abstract void seekTo(int t); /** * Seek a specific position from the current position * * @param d offset from current position (positive or negative) */ - public void seekDelta(final int d) { - executor.submit(() -> { - playerLock.lock(); - int currentPosition = getPosition(); - if (currentPosition != INVALID_TIME) { - seekToSync(currentPosition + d); - } else { - Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); - } - - playerLock.unlock(); - }); - } + public abstract void seekDelta(int d); /** * Seek to the start of the specified chapter. @@ -467,193 +139,64 @@ public class PlaybackServiceMediaPlayer { /** * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved. */ - public int getDuration() { - if (!playerLock.tryLock()) { - return INVALID_TIME; - } - - int retVal = INVALID_TIME; - if (playerStatus == PlayerStatus.PLAYING - || playerStatus == PlayerStatus.PAUSED - || playerStatus == PlayerStatus.PREPARED) { - retVal = mediaPlayer.getDuration(); - } else if (media != null && media.getDuration() > 0) { - retVal = media.getDuration(); - } - - playerLock.unlock(); - return retVal; - } + public abstract int getDuration(); /** * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved. */ - public int getPosition() { - try { - if (!playerLock.tryLock(50, TimeUnit.MILLISECONDS)) { - return INVALID_TIME; - } - } catch (InterruptedException e) { - return INVALID_TIME; - } + public abstract int getPosition(); - int retVal = INVALID_TIME; - if (playerStatus == PlayerStatus.PLAYING - || playerStatus == PlayerStatus.PAUSED - || playerStatus == PlayerStatus.PREPARED - || playerStatus == PlayerStatus.SEEKING) { - retVal = mediaPlayer.getCurrentPosition(); - } - if (retVal <= 0 && media != null && media.getPosition() >= 0) { - retVal = media.getPosition(); - } - - playerLock.unlock(); - Log.d(TAG, "getPosition() -> " + retVal); - return retVal; - } + public abstract boolean isStartWhenPrepared(); - public boolean isStartWhenPrepared() { - return startWhenPrepared.get(); - } - - public void setStartWhenPrepared(boolean startWhenPrepared) { - this.startWhenPrepared.set(startWhenPrepared); - } + public abstract void setStartWhenPrepared(boolean startWhenPrepared); /** * Returns true if the playback speed can be adjusted. */ - public boolean canSetSpeed() { - boolean retVal = false; - if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) { - retVal = (mediaPlayer).canSetSpeed(); - } - return retVal; - } - - /** - * Sets the playback speed. - * This method is executed on the caller's thread. - */ - private void setSpeedSync(float speed) { - playerLock.lock(); - if (media != null && media.getMediaType() == MediaType.AUDIO) { - if (mediaPlayer.canSetSpeed()) { - mediaPlayer.setPlaybackSpeed(speed); - Log.d(TAG, "Playback speed was set to " + speed); - callback.playbackSpeedChanged(speed); - } - } - playerLock.unlock(); - } + public abstract boolean canSetSpeed(); /** * Sets the playback speed. * This method is executed on an internal executor service. */ - public void setSpeed(final float speed) { - executor.submit(() -> setSpeedSync(speed)); - } + public abstract void setSpeed(float speed); /** * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned. */ - public float getPlaybackSpeed() { - if (!playerLock.tryLock()) { - return 1; - } - - float retVal = 1; - if ((playerStatus == PlayerStatus.PLAYING - || playerStatus == PlayerStatus.PAUSED - || playerStatus == PlayerStatus.PREPARED) && mediaPlayer.canSetSpeed()) { - retVal = mediaPlayer.getCurrentSpeedMultiplier(); - } - playerLock.unlock(); - return retVal; - } + public abstract float getPlaybackSpeed(); /** * Sets the playback volume. * This method is executed on an internal executor service. */ - public void setVolume(final float volumeLeft, float volumeRight) { - executor.submit(() -> setVolumeSync(volumeLeft, volumeRight)); - } - - /** - * Sets the playback volume. - * This method is executed on the caller's thread. - */ - private void setVolumeSync(float volumeLeft, float volumeRight) { - playerLock.lock(); - if (media != null && media.getMediaType() == MediaType.AUDIO) { - mediaPlayer.setVolume(volumeLeft, volumeRight); - Log.d(TAG, "Media player volume was set to " + volumeLeft + " " + volumeRight); - } - playerLock.unlock(); - } + public abstract void setVolume(float volumeLeft, float volumeRight); /** * Returns true if the mediaplayer can mix stereo down to mono */ - public boolean canDownmix() { - boolean retVal = false; - if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) { - retVal = mediaPlayer.canDownmix(); - } - return retVal; - } + public abstract boolean canDownmix(); - public void setDownmix(boolean enable) { - playerLock.lock(); - if (media != null && media.getMediaType() == MediaType.AUDIO) { - mediaPlayer.setDownmix(enable); - Log.d(TAG, "Media player downmix was set to " + enable); - } - playerLock.unlock(); - } + public abstract void setDownmix(boolean enable); - public MediaType getCurrentMediaType() { - return mediaType; - } + public abstract MediaType getCurrentMediaType(); - public boolean isStreaming() { - return stream; - } + 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 void shutdown() { - executor.shutdown(); - if (mediaPlayer != null) { - mediaPlayer.release(); - } - releaseWifiLockIfNecessary(); - } + public abstract void shutdownQuietly(); - public void setVideoSurface(final SurfaceHolder surface) { - executor.submit(() -> { - playerLock.lock(); - if (mediaPlayer != null) { - mediaPlayer.setDisplay(surface); - } - playerLock.unlock(); - }); - } + public abstract void setVideoSurface(SurfaceHolder surface); - public void resetVideoSurface() { - executor.submit(() -> { - playerLock.lock(); - Log.d(TAG, "Resetting video surface"); - mediaPlayer.setDisplay(null); - reinit(); - playerLock.unlock(); - }); - } + public abstract void resetVideoSurface(); /** * Return width and height of the currently playing video as a pair. @@ -662,30 +205,15 @@ public class PlaybackServiceMediaPlayer { * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return * invalid values. */ - public Pair<Integer, Integer> getVideoSize() { - if (!playerLock.tryLock()) { - // use cached value if lock can't be aquired - return videoSize; - } - Pair<Integer, Integer> res; - if (mediaPlayer == null || playerStatus == PlayerStatus.ERROR || mediaType != MediaType.VIDEO) { - res = null; - } else { - VideoPlayer vp = (VideoPlayer) mediaPlayer; - videoSize = new Pair<>(vp.getVideoWidth(), vp.getVideoHeight()); - res = videoSize; - } - playerLock.unlock(); - return res; - } + 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 synchronized PSMPInfo getPSMPInfo() { - return new PSMPInfo(playerStatus, media); + public final synchronized PSMPInfo getPSMPInfo() { + return new PSMPInfo(playerStatus, getPlayable()); } /** @@ -704,145 +232,27 @@ public class PlaybackServiceMediaPlayer { * could result in nonsensical results (like a status of PLAYING, but a null playable) * @return the current media. May be null */ - public Playable getPlayable() { - return media; - } - - /** - * 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). - * - * @param newStatus The new PlayerStatus. This must not be null. - * @param newMedia The new playable object of the PSMP object. This can be null. - */ - private synchronized void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) { - Log.d(TAG, "Setting player status to " + newStatus); - - this.playerStatus = newStatus; - this.media = newMedia; - if (playerStatus != null) { - Log.d(TAG, "playerStatus: " + playerStatus.toString()); - } - - callback.statusChanged(new PSMPInfo(playerStatus, media)); - } - - private IPlayer createMediaPlayer() { - if (mediaPlayer != null) { - mediaPlayer.release(); - } - if (media == null || media.getMediaType() == MediaType.VIDEO) { - mediaPlayer = new VideoPlayer(); - } else { - mediaPlayer = new AudioPlayer(context); - } - mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); - mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); - return setMediaPlayerListeners(mediaPlayer); - } - - private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() { - - @Override - public void onAudioFocusChange(final int focusChange) { - executor.submit(new Runnable() { - @Override - public void run() { - playerLock.lock(); - - // If there is an incoming call, playback should be paused permanently - TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - final int callState = (tm != null) ? tm.getCallState() : 0; - Log.i(TAG, "Call state:" + callState); - - if (focusChange == AudioManager.AUDIOFOCUS_LOSS || - (!UserPreferences.shouldResumeAfterCall() && callState != TelephonyManager.CALL_STATE_IDLE)) { - Log.d(TAG, "Lost audio focus"); - pause(true, false); - callback.shouldStop(); - } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { - Log.d(TAG, "Gained audio focus"); - if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now - resume(); - } else { // we ducked => raise audio level back - setVolumeSync(UserPreferences.getLeftVolume(), - UserPreferences.getRightVolume()); - } - } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { - if (playerStatus == PlayerStatus.PLAYING) { - if (!UserPreferences.shouldPauseForFocusLoss()) { - Log.d(TAG, "Lost audio focus temporarily. Ducking..."); - final float DUCK_FACTOR = 0.25f; - setVolumeSync(DUCK_FACTOR * UserPreferences.getLeftVolume(), - DUCK_FACTOR * UserPreferences.getRightVolume()); - pausedBecauseOfTransientAudiofocusLoss = false; - } else { - Log.d(TAG, "Lost audio focus temporarily. Could duck, but won't, pausing..."); - pause(false, false); - pausedBecauseOfTransientAudiofocusLoss = true; - } - } - } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { - if (playerStatus == PlayerStatus.PLAYING) { - Log.d(TAG, "Lost audio focus temporarily. Pausing..."); - pause(false, false); - pausedBecauseOfTransientAudiofocusLoss = true; - } - } - playerLock.unlock(); - } - }); - } - }; - + public abstract Playable getPlayable(); - public void endPlayback(final boolean wasSkipped) { - executor.submit(() -> { - playerLock.lock(); - releaseWifiLockIfNecessary(); + protected abstract void setPlayable(Playable playable); - boolean isPlaying = playerStatus == PlayerStatus.PLAYING; - - if (playerStatus != PlayerStatus.INDETERMINATE) { - setPlayerStatus(PlayerStatus.INDETERMINATE, media); - } - if (mediaPlayer != null) { - mediaPlayer.reset(); - - } - audioManager.abandonAudioFocus(audioFocusChangeListener); - callback.endPlayback(isPlaying, wasSkipped); - - playerLock.unlock(); - }); - } + public abstract void endPlayback(boolean wasSkipped, boolean switchingPlayers); /** - * Moves the PlaybackServiceMediaPlayer into STOPPED state. This call is only valid if the player is currently in + * Moves the PSMP into STOPPED state. This call is only valid if the player is currently in * INDETERMINATE state, for example after a call to endPlayback. * This method will only take care of changing the PlayerStatus of this object! Other tasks like * abandoning audio focus have to be done with other methods. */ - public void stop() { - executor.submit(() -> { - playerLock.lock(); - releaseWifiLockIfNecessary(); - - if (playerStatus == PlayerStatus.INDETERMINATE) { - setPlayerStatus(PlayerStatus.STOPPED, null); - } else { - Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); - } - playerLock.unlock(); + public abstract void stop(); - }); - } + /** + * @return {@code true} if the WifiLock feature should be used, {@code false} otherwise. + */ + protected abstract boolean shouldLockWifi(); - private synchronized void acquireWifiLockIfNecessary() { - if (stream) { + protected final synchronized void acquireWifiLockIfNecessary() { + if (shouldLockWifi()) { if (wifiLock == null) { wifiLock = ((WifiManager) context.getSystemService(Context.WIFI_SERVICE)) .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); @@ -852,22 +262,52 @@ public class PlaybackServiceMediaPlayer { } } - private synchronized void releaseWifiLockIfNecessary() { + protected final synchronized void releaseWifiLockIfNecessary() { if (wifiLock != null && wifiLock.isHeld()) { wifiLock.release(); } } /** - * Holds information about a PSMP object. + * 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). + * + * @param newStatus The new PlayerStatus. This must not be null. + * @param newMedia The new playable object of the PSMP object. This can be null. */ - public class PSMPInfo { - public PlayerStatus playerStatus; - public Playable playable; + protected synchronized final void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) { + Log.d(TAG, this.getClass().getSimpleName() + ": Setting player status to " + newStatus); - public PSMPInfo(PlayerStatus playerStatus, Playable playable) { - this.playerStatus = playerStatus; - this.playable = playable; + this.playerStatus = newStatus; + setPlayable(newMedia); + + if (playerStatus != null) { + Log.d(TAG, "playerStatus: " + playerStatus.toString()); + } + + callback.statusChanged(new PSMPInfo(playerStatus, getPlayable())); + } + + protected void smartMarkAsPlayed(Playable media) { + if(media != null && media instanceof FeedMedia) { + FeedMedia oldMedia = (FeedMedia) media; + if(oldMedia.hasAlmostEnded()) { + Log.d(TAG, "smart mark as read"); + FeedItem item = oldMedia.getItem(); + if (item == null) { + return; + } + DBWriter.markItemPlayed(item, FeedItem.PLAYED, false); + DBWriter.removeQueueItem(context, item, false); + DBWriter.addItemToPlaybackHistory(oldMedia); + if (item.getFeed().getPreferences().getCurrentAutoDelete()) { + Log.d(TAG, "Delete " + oldMedia.toString()); + DBWriter.deleteFeedMediaOfItem(context, oldMedia.getId()); + } + } } } @@ -882,114 +322,25 @@ public class PlaybackServiceMediaPlayer { void onBufferingUpdate(int percent); - void updateMediaSessionMetadata(Playable p); + void reloadUI(); - boolean onMediaPlayerInfo(int code); + boolean onMediaPlayerInfo(int code, @StringRes int resourceId); boolean onMediaPlayerError(Object inObj, int what, int extra); - boolean endPlayback(boolean playNextEpisode, boolean wasSkipped); - } - - private IPlayer setMediaPlayerListeners(IPlayer mp) { - if (mp != null && media != null) { - if (media.getMediaType() == MediaType.AUDIO) { - ((AudioPlayer) mp) - .setOnCompletionListener(audioCompletionListener); - ((AudioPlayer) mp) - .setOnSeekCompleteListener(audioSeekCompleteListener); - ((AudioPlayer) mp).setOnErrorListener(audioErrorListener); - ((AudioPlayer) mp) - .setOnBufferingUpdateListener(audioBufferingUpdateListener); - ((AudioPlayer) mp).setOnInfoListener(audioInfoListener); - ((AudioPlayer) mp).setOnSpeedAdjustmentAvailableChangedListener(audioSetSpeedAbilityListener); - } else { - ((VideoPlayer) mp) - .setOnCompletionListener(videoCompletionListener); - ((VideoPlayer) mp) - .setOnSeekCompleteListener(videoSeekCompleteListener); - ((VideoPlayer) mp).setOnErrorListener(videoErrorListener); - ((VideoPlayer) mp) - .setOnBufferingUpdateListener(videoBufferingUpdateListener); - ((VideoPlayer) mp).setOnInfoListener(videoInfoListener); - } - } - return mp; - } - - private final MediaPlayer.OnCompletionListener audioCompletionListener = - mp -> genericOnCompletion(); - - private final android.media.MediaPlayer.OnCompletionListener videoCompletionListener = - mp -> genericOnCompletion(); - - private void genericOnCompletion() { - endPlayback(false); - } - - private final MediaPlayer.OnBufferingUpdateListener audioBufferingUpdateListener = - (mp, percent) -> genericOnBufferingUpdate(percent); - - private final android.media.MediaPlayer.OnBufferingUpdateListener videoBufferingUpdateListener = - (mp, percent) -> genericOnBufferingUpdate(percent); - - private void genericOnBufferingUpdate(int percent) { - callback.onBufferingUpdate(percent); + boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers); } - private final MediaPlayer.OnInfoListener audioInfoListener = - (mp, what, extra) -> genericInfoListener(what); - - private final android.media.MediaPlayer.OnInfoListener videoInfoListener = - (mp, what, extra) -> genericInfoListener(what); - - private boolean genericInfoListener(int what) { - return callback.onMediaPlayerInfo(what); - } + /** + * Holds information about a PSMP object. + */ + public static class PSMPInfo { + public PlayerStatus playerStatus; + public Playable playable; - private final MediaPlayer.OnSpeedAdjustmentAvailableChangedListener - audioSetSpeedAbilityListener = new MediaPlayer.OnSpeedAdjustmentAvailableChangedListener() { - @Override - public void onSpeedAdjustmentAvailableChanged(MediaPlayer arg0, boolean speedAdjustmentAvailable) { - callback.setSpeedAbilityChanged(); + public PSMPInfo(PlayerStatus playerStatus, Playable playable) { + this.playerStatus = playerStatus; + this.playable = playable; } - }; - - - private final MediaPlayer.OnErrorListener audioErrorListener = - (mp, what, extra) -> { - if(mp.canFallback()) { - mp.fallback(); - return true; - } else { - return genericOnError(mp, what, extra); - } - }; - - private final android.media.MediaPlayer.OnErrorListener videoErrorListener = this::genericOnError; - - private boolean genericOnError(Object inObj, int what, int extra) { - return callback.onMediaPlayerError(inObj, what, extra); - } - - private final MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener = - mp -> genericSeekCompleteListener(); - - private final android.media.MediaPlayer.OnSeekCompleteListener videoSeekCompleteListener = - mp -> genericSeekCompleteListener(); - - private void genericSeekCompleteListener() { - Thread t = new Thread(() -> { - Log.d(TAG, "genericSeekCompleteListener"); - if(seekLatch != null) { - seekLatch.countDown(); - } - playerLock.lock(); - if (playerStatus == PlayerStatus.SEEKING) { - setPlayerStatus(statusBeforeSeeking, media); - } - playerLock.unlock(); - }); - t.start(); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java index 7c666abd5..8a222d7ec 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java @@ -1,24 +1,33 @@ package de.danoeh.antennapod.core.service.playback; public enum PlayerStatus { - INDETERMINATE, // player is currently changing its state, listeners should wait until the player has left this state. - ERROR, - PREPARING, - PAUSED, - PLAYING, - STOPPED, - PREPARED, - SEEKING, - INITIALIZING, // playback service is loading the Playable's metadata - INITIALIZED; // playback service was started, data source of media player was set. + INDETERMINATE(0), // player is currently changing its state, listeners should wait until the player has left this state. + 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 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/core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java new file mode 100644 index 000000000..b9068447e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java @@ -0,0 +1,587 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.content.Context; +import android.media.MediaPlayer; +import android.support.annotation.NonNull; +import android.util.Log; +import android.util.Pair; +import android.view.SurfaceHolder; + +import com.google.android.gms.cast.Cast; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; +import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; + +import java.util.concurrent.atomic.AtomicBoolean; + +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.cast.CastConsumer; +import de.danoeh.antennapod.core.cast.CastManager; +import de.danoeh.antennapod.core.cast.CastUtils; +import de.danoeh.antennapod.core.cast.DefaultCastConsumer; +import de.danoeh.antennapod.core.cast.RemoteMedia; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; +import de.danoeh.antennapod.core.util.playback.Playable; + +/** + * Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices. + */ +public class RemotePSMP extends PlaybackServiceMediaPlayer { + + public static final String TAG = "RemotePSMP"; + + public static final int CAST_ERROR = 3001; + + public static final int CAST_ERROR_PRIORITY_HIGH = 3005; + + private final CastManager castMgr; + + private volatile Playable media; + private volatile MediaInfo remoteMedia; + private volatile MediaType mediaType; + + private final AtomicBoolean isBuffering; + + private final AtomicBoolean startWhenPrepared; + + public RemotePSMP(@NonNull Context context, @NonNull PSMPCallback callback) { + super(context, callback); + + castMgr = CastManager.getInstance(); + media = null; + mediaType = null; + startWhenPrepared = new AtomicBoolean(false); + isBuffering = new AtomicBoolean(false); + + try { + if (castMgr.isConnected() && castMgr.isRemoteMediaLoaded()) { + // updates the state, but does not start playing new media if it was going to + onRemoteMediaPlayerStatusUpdated( + ((p, playNextEpisode, wasSkipped, switchingPlayers) -> + this.callback.endPlayback(p, false, wasSkipped, switchingPlayers))); + } + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to do initial check for loaded media", e); + } + + castMgr.addCastConsumer(castConsumer); + //TODO + } + + private CastConsumer castConsumer = new DefaultCastConsumer() { + @Override + public void onRemoteMediaPlayerMetadataUpdated() { + RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); + } + + @Override + public void onRemoteMediaPlayerStatusUpdated() { + RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); + } + + @Override + public void onMediaLoadResult(int statusCode) { + if (playerStatus == PlayerStatus.PREPARING) { + if (statusCode == CastStatusCodes.SUCCESS) { + setPlayerStatus(PlayerStatus.PREPARED, media); + if (media.getDuration() == 0) { + Log.d(TAG, "Setting duration of media"); + try { + media.setDuration((int) castMgr.getMediaDuration()); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to get remote media's duration"); + } + } + } else if (statusCode != CastStatusCodes.REPLACED){ + Log.d(TAG, "Remote media failed to load"); + setPlayerStatus(PlayerStatus.INITIALIZED, media); + } + } else { + Log.d(TAG, "onMediaLoadResult called, but Player Status wasn't in preparing state, so we ignore the result"); + } + } + + @Override + public void onApplicationStatusChanged(String appStatus) { + if (playerStatus != PlayerStatus.PLAYING) { + Log.d(TAG, "onApplicationStatusChanged, but no media was playing"); + return; + } + boolean playbackEnded = false; + try { + int standbyState = castMgr.getApplicationStandbyState(); + Log.d(TAG, "standbyState: " + standbyState); + playbackEnded = standbyState == Cast.STANDBY_STATE_YES; + } catch (IllegalStateException e) { + Log.d(TAG, "unable to get standbyState on onApplicationStatusChanged()"); + } + if (playbackEnded) { + setPlayerStatus(PlayerStatus.INDETERMINATE, media); + callback.endPlayback(media, true, false, false); + } + } + + @Override + public void onFailed(int resourceId, int statusCode) { + callback.onMediaPlayerInfo(CAST_ERROR, resourceId); + } + }; + + private void setBuffering(boolean buffering) { + if (buffering && isBuffering.compareAndSet(false, true)) { + callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); + } else if (!buffering && isBuffering.compareAndSet(true, false)) { + callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); + } + } + + private Playable localVersion(MediaInfo info){ + if (info == null) { + return null; + } + if (CastUtils.matches(info, media)) { + return media; + } + return CastUtils.getPlayable(info, true); + } + + private MediaInfo remoteVersion(Playable playable) { + if (playable == null) { + return null; + } + if (CastUtils.matches(remoteMedia, playable)) { + return remoteMedia; + } + if (playable instanceof FeedMedia) { + return CastUtils.convertFromFeedMedia((FeedMedia) playable); + } + if (playable instanceof RemoteMedia) { + return ((RemoteMedia) playable).extractMediaInfo(); + } + return null; + } + + private void onRemoteMediaPlayerStatusUpdated(@NonNull EndPlaybackCall endPlaybackCall) { + MediaStatus status = castMgr.getMediaStatus(); + if (status == null) { + Log.d(TAG, "Received null MediaStatus"); + //setBuffering(false); + //setPlayerStatus(PlayerStatus.INDETERMINATE, null); + return; + } else { + Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState()); + } + Playable currentMedia = localVersion(status.getMediaInfo()); + boolean updateUI = currentMedia != media; + if (currentMedia != null) { + long position = status.getStreamPosition(); + if (position > 0 && currentMedia.getPosition() == 0) { + currentMedia.setPosition((int) position); + } + } + int state = status.getPlayerState(); + setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING); + switch (state) { + case MediaStatus.PLAYER_STATE_PLAYING: + setPlayerStatus(PlayerStatus.PLAYING, currentMedia); + break; + case MediaStatus.PLAYER_STATE_PAUSED: + setPlayerStatus(PlayerStatus.PAUSED, currentMedia); + break; + case MediaStatus.PLAYER_STATE_BUFFERING: + setPlayerStatus(playerStatus, currentMedia); + break; + case MediaStatus.PLAYER_STATE_IDLE: + int reason = status.getIdleReason(); + switch (reason) { + case MediaStatus.IDLE_REASON_CANCELED: + // check if we're already loading something else + if (!updateUI || media == null) { + setPlayerStatus(PlayerStatus.STOPPED, currentMedia); + } else { + updateUI = false; + } + break; + case MediaStatus.IDLE_REASON_INTERRUPTED: + // check if we're already loading something else + if (!updateUI || media == null) { + setPlayerStatus(PlayerStatus.PREPARING, currentMedia); + } else { + updateUI = false; + } + break; + case MediaStatus.IDLE_REASON_NONE: + setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia); + break; + case MediaStatus.IDLE_REASON_FINISHED: + boolean playing = playerStatus == PlayerStatus.PLAYING; + setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); + endPlaybackCall.endPlayback(currentMedia,playing, false, false); + // endPlayback already updates the UI, so no need to trigger it ourselves + updateUI = false; + break; + case MediaStatus.IDLE_REASON_ERROR: + Log.w(TAG, "Got an error status from the Chromecast. Skipping, if possible, to the next episode..."); + setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); + callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, + R.string.cast_failed_media_error_skipping); + endPlaybackCall.endPlayback(currentMedia, startWhenPrepared.get(), true, false); + // endPlayback already updates the UI, so no need to trigger it ourselves + updateUI = false; + } + break; + case MediaStatus.PLAYER_STATE_UNKNOWN: + //is this right? + setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); + break; + default: + Log.e(TAG, "Remote media state undetermined!"); + setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); + } + if (updateUI) { + callback.reloadUI(); + } + } + + @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(de.danoeh.antennapod.core.util.playback.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)) { + Log.d(TAG, "media provided is not compatible with cast device"); + callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_not_castable); + callback.endPlayback(playable, startWhenPrepared, true, false); + 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 + try { + if (castMgr.isRemoteMediaPlaying()) { + setPlayerStatus(PlayerStatus.PAUSED, media); + } + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to determine whether media was playing, falling back to stored player status", e); + // this might end up just being pointless if we need to query the remote device for the position + if (playerStatus == PlayerStatus.PLAYING) { + setPlayerStatus(PlayerStatus.PAUSED, media); + } + } + smartMarkAsPlayed(media); + + + setPlayerStatus(PlayerStatus.INDETERMINATE, null); + } + } + + this.media = playable; + remoteMedia = remoteVersion(playable); + //this.stream = stream; + this.mediaType = media.getMediaType(); + this.startWhenPrepared.set(startWhenPrepared); + setPlayerStatus(PlayerStatus.INITIALIZING, media); + try { + media.loadMetadata(); + callback.reloadUI(); + setPlayerStatus(PlayerStatus.INITIALIZED, media); + if (prepareImmediately) { + prepare(); + } + } catch (Playable.PlayableException e) { + Log.e(TAG, "Error while loading media metadata", e); + setPlayerStatus(PlayerStatus.STOPPED, null); + } + } + + @Override + public void resume() { + try { + // TODO see comment on prepare() + // setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); + if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { + int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( + media.getPosition(), + media.getLastPlayedTime()); + castMgr.play(newPosition); + } + castMgr.play(); + } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to resume remote playback", e); + } + } + + @Override + public void pause(boolean abandonFocus, boolean reinit) { + try { + if (castMgr.isRemoteMediaPlaying()) { + castMgr.pause(); + } + } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to pause", e); + } + } + + @Override + public void prepare() { + if (playerStatus == PlayerStatus.INITIALIZED) { + Log.d(TAG, "Preparing media player"); + setPlayerStatus(PlayerStatus.PREPARING, media); + try { + int position = media.getPosition(); + if (position > 0) { + position = RewindAfterPauseUtils.calculatePositionWithRewind( + position, + media.getLastPlayedTime()); + } + // TODO We're not supporting user set stream volume yet, as we need to make a UI + // that doesn't allow changing playback speed or have different values for left/right + //setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); + castMgr.loadMedia(remoteMedia, startWhenPrepared.get(), position); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Error loading media", e); + setPlayerStatus(PlayerStatus.INITIALIZED, media); + } + } + } + + @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) { + //TODO check other seek implementations and see if there's no issue with sending too many seek commands to the remote media player + try { + if (castMgr.isRemoteMediaLoaded()) { + setPlayerStatus(PlayerStatus.SEEKING, media); + castMgr.seek(t); + } else if (media != null && playerStatus == PlayerStatus.INITIALIZED){ + media.setPosition(t); + startWhenPrepared.set(false); + prepare(); + } + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to seek", e); + } + } + + @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 = INVALID_TIME; + boolean prepared; + try { + prepared = castMgr.isRemoteMediaLoaded(); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to check if remote media is loaded", e); + prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); + } + if (prepared) { + try { + retVal = (int) castMgr.getMediaDuration(); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to determine remote media's duration", e); + } + } + if(retVal == INVALID_TIME && media != null && media.getDuration() > 0) { + retVal = media.getDuration(); + } + Log.d(TAG, "getDuration() -> " + retVal); + return retVal; + } + + @Override + public int getPosition() { + int retVal = INVALID_TIME; + boolean prepared; + try { + prepared = castMgr.isRemoteMediaLoaded(); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to check if remote media is loaded", e); + prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); + } + if (prepared) { + try { + retVal = (int) castMgr.getCurrentMediaPosition(); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Unable to determine remote media's position", e); + } + } + if(retVal <= 0 && media != null && media.getPosition() >= 0) { + retVal = media.getPosition(); + } + Log.d(TAG, "getPosition() -> " + retVal); + return retVal; + } + + @Override + public boolean isStartWhenPrepared() { + return startWhenPrepared.get(); + } + + @Override + public void setStartWhenPrepared(boolean startWhenPrepared) { + this.startWhenPrepared.set(startWhenPrepared); + } + + //TODO I believe some parts of the code make the same decision skipping this check, so that + //should be changed as well + @Override + public boolean canSetSpeed() { + return false; + } + + @Override + public void setSpeed(float speed) { + throw new UnsupportedOperationException("Setting playback speed unsupported for Remote Playback"); + } + + @Override + public float getPlaybackSpeed() { + return 1; + } + + @Override + public void setVolume(float volumeLeft, float volumeRight) { + Log.d(TAG, "Setting the Stream volume on Remote Media Player"); + double volume = (volumeLeft+volumeRight)/2; + if (volume > 1.0) { + volume = 1.0; + } + if (volume < 0.0) { + volume = 0.0; + } + try { + castMgr.setStreamVolume(volume); + } catch (TransientNetworkDisconnectionException | NoConnectionException | CastException e) { + Log.e(TAG, "Unable to set the volume", e); + } + } + + @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() { + castMgr.removeCastConsumer(castConsumer); + } + + @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 void endPlayback(boolean wasSkipped, boolean switchingPlayers) { + Log.d(TAG, "endPlayback() called"); + boolean isPlaying = playerStatus == PlayerStatus.PLAYING; + try { + isPlaying = castMgr.isRemoteMediaPlaying(); + } catch (TransientNetworkDisconnectionException | NoConnectionException e) { + Log.e(TAG, "Could not determine if media is playing", e); + } + // TODO make sure we stop playback whenever there's no next episode. + if (playerStatus != PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.INDETERMINATE, media); + } + callback.endPlayback(media, isPlaying, wasSkipped, switchingPlayers); + } + + @Override + public void stop() { + if (playerStatus == PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.STOPPED, null); + } else { + Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); + } + } + + @Override + protected boolean shouldLockWifi() { + return false; + } + + private interface EndPlaybackCall { + boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java index 927639e69..55b608dce 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java @@ -92,6 +92,18 @@ public class NetworkUtils { return mWifi.isConnected(); } + /** + * Returns the SSID of the wifi connection, or <code>null</code> if there is no wifi. + */ + public static String getWifiSsid() { + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + if (wifiInfo != null) { + return wifiInfo.getSSID(); + } + return null; + } + public static Observable<Long> getFeedMediaSizeObservable(FeedMedia media) { return Observable.create(new Observable.OnSubscribe<Long>() { @Override diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/Supplier.java b/core/src/main/java/de/danoeh/antennapod/core/util/Supplier.java new file mode 100644 index 000000000..796b03154 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/Supplier.java @@ -0,0 +1,5 @@ +package de.danoeh.antennapod.core.util; + +public interface Supplier<T> { + T get(); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java index 0650225f0..5ba7f11d6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java @@ -6,7 +6,7 @@ import de.danoeh.antennapod.core.R; /** Utility class for MediaPlayer errors. */ public class MediaPlayerError { - + /** Get a human-readable string for a specific error code. */ public static String getErrorString(Context context, int code) { int resId; diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java index 6459d86ed..201efbc81 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -8,6 +8,7 @@ import android.util.Log; import java.util.List; import de.danoeh.antennapod.core.asynctask.ImageResource; +import de.danoeh.antennapod.core.cast.RemoteMedia; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; @@ -183,6 +184,9 @@ public interface Playable extends Parcelable, case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA: result = createExternalMediaInstance(pref); break; + case RemoteMedia.PLAYABLE_TYPE_REMOTE_MEDIA: + result = createRemoteMediaInstance(pref); + break; } if (result == null) { Log.e(TAG, "Could not restore Playable object from preferences"); @@ -211,6 +215,12 @@ public interface Playable extends Parcelable, } return result; } + + private static Playable createRemoteMediaInstance(SharedPreferences pref) { + //TODO there's probably no point in restoring RemoteMedia from preferences, because we + //only care about it while it's playing on the cast device. + return null; + } } class PlayableException extends Exception { diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java index 1409ffe09..7870c747e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java @@ -176,7 +176,7 @@ public abstract class PlaybackController { if(serviceBinder != null) { serviceBinder.unsubscribe(); } - serviceBinder = Observable.fromCallable(() -> getPlayLastPlayedMediaIntent()) + serviceBinder = Observable.fromCallable(this::getPlayLastPlayedMediaIntent) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(intent -> { @@ -338,6 +338,9 @@ public abstract class PlaybackController { break; case PlaybackService.NOTIFICATION_TYPE_SET_SPEED_ABILITY_CHANGED: onSetSpeedAbilityChanged(); + break; + case PlaybackService.NOTIFICATION_TYPE_SHOW_TOAST: + postStatusMsg(code, true); } } @@ -392,7 +395,7 @@ public abstract class PlaybackController { } /** - * Is called whenever the PlaybackService changes it's status. This method + * Is called whenever the PlaybackService changes its status. This method * should be used to update the GUI or start/cancel background threads. */ private void handleStatus() { @@ -401,7 +404,8 @@ public abstract class PlaybackController { final CharSequence playText = activity.getString(R.string.play_label); final CharSequence pauseText = activity.getString(R.string.pause_label); - if (PlaybackService.getCurrentMediaType() == MediaType.AUDIO) { + if (PlaybackService.getCurrentMediaType() == MediaType.AUDIO || + PlaybackService.isCasting()) { TypedArray res = activity.obtainStyledAttributes(new int[]{ R.attr.av_play_big, R.attr.av_pause_big}); playResource = res.getResourceId(0, R.drawable.ic_play_arrow_grey600_36dp); @@ -415,7 +419,7 @@ public abstract class PlaybackController { Log.d(TAG, "status: " + status.toString()); switch (status) { case ERROR: - postStatusMsg(R.string.player_error_msg); + postStatusMsg(R.string.player_error_msg, false); handleError(MediaPlayer.MEDIA_ERROR_UNKNOWN); break; case PAUSED: @@ -424,14 +428,16 @@ public abstract class PlaybackController { cancelPositionObserver(); onPositionObserverUpdate(); updatePlayButtonAppearance(playResource, playText); - if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { + if (!PlaybackService.isCasting() && + PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { setScreenOn(false); } break; case PLAYING: clearStatusMsg(); checkMediaInfoLoaded(); - if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { + if (!PlaybackService.isCasting() && + PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { onAwaitingVideoSurface(); setScreenOn(true); } @@ -439,7 +445,7 @@ public abstract class PlaybackController { updatePlayButtonAppearance(pauseResource, pauseText); break; case PREPARING: - postStatusMsg(R.string.player_preparing_msg); + postStatusMsg(R.string.player_preparing_msg, false); checkMediaInfoLoaded(); if (playbackService != null) { if (playbackService.isStartWhenPrepared()) { @@ -450,16 +456,16 @@ public abstract class PlaybackController { } break; case STOPPED: - postStatusMsg(R.string.player_stopped_msg); + postStatusMsg(R.string.player_stopped_msg, false); break; case PREPARED: checkMediaInfoLoaded(); - postStatusMsg(R.string.player_ready_msg); + postStatusMsg(R.string.player_ready_msg, false); updatePlayButtonAppearance(playResource, playText); break; case SEEKING: onPositionObserverUpdate(); - postStatusMsg(R.string.player_seeking_msg); + postStatusMsg(R.string.player_seeking_msg, false); break; case INITIALIZED: checkMediaInfoLoaded(); @@ -485,7 +491,7 @@ public abstract class PlaybackController { return null; } - public void postStatusMsg(int msg) {} + public void postStatusMsg(int msg, boolean showToast) {} public void clearStatusMsg() {} @@ -502,8 +508,9 @@ public abstract class PlaybackController { private void queryService() { Log.d(TAG, "Querying service info"); if (playbackService != null) { - status = playbackService.getStatus(); - media = playbackService.getPlayable(); + PlaybackServiceMediaPlayer.PSMPInfo info = playbackService.getPSMPInfo(); + status = info.playerStatus; + media = info.playable; /* if (media == null) { Log.w(TAG, @@ -712,8 +719,9 @@ public abstract class PlaybackController { } } - public boolean isPlayingVideo() { - return playbackService != null && PlaybackService.getCurrentMediaType() == MediaType.VIDEO; + public boolean isPlayingVideoLocally() { + return playbackService != null && PlaybackService.getCurrentMediaType() == MediaType.VIDEO + && !PlaybackService.isCasting(); } public Pair<Integer, Integer> getVideoSize() { @@ -745,6 +753,7 @@ public abstract class PlaybackController { public void reinitServiceIfPaused() { if (playbackService != null && playbackService.isStreaming() + && !PlaybackService.isCasting() && (playbackService.getStatus() == PlayerStatus.PAUSED || (playbackService.getStatus() == PlayerStatus.PREPARING && !playbackService.isStartWhenPrepared()))) { diff --git a/core/src/main/res/drawable-hdpi/ic_audiotrack_light.png b/core/src/main/res/drawable-hdpi/ic_audiotrack_light.png Binary files differnew file mode 100644 index 000000000..04e23e1f6 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_audiotrack_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_disabled_light.png b/core/src/main/res/drawable-hdpi/ic_cast_disabled_light.png Binary files differnew file mode 100644 index 000000000..c0a55d555 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_disabled_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_disconnect_grey600_36dp.png b/core/src/main/res/drawable-hdpi/ic_cast_disconnect_grey600_36dp.png Binary files differnew file mode 100644 index 000000000..07da25ec0 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_disconnect_grey600_36dp.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_disconnect_white_36dp.png b/core/src/main/res/drawable-hdpi/ic_cast_disconnect_white_36dp.png Binary files differnew file mode 100644 index 000000000..ff9d97fd1 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_disconnect_white_36dp.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_light.png b/core/src/main/res/drawable-hdpi/ic_cast_light.png Binary files differnew file mode 100644 index 000000000..b0c581a0e --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_off_light.png b/core/src/main/res/drawable-hdpi/ic_cast_off_light.png Binary files differnew file mode 100644 index 000000000..5f3c0179c --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_off_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_on_0_light.png b/core/src/main/res/drawable-hdpi/ic_cast_on_0_light.png Binary files differnew file mode 100644 index 000000000..e872693a4 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_on_0_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_on_1_light.png b/core/src/main/res/drawable-hdpi/ic_cast_on_1_light.png Binary files differnew file mode 100644 index 000000000..d8be1ebc6 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_on_1_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_on_2_light.png b/core/src/main/res/drawable-hdpi/ic_cast_on_2_light.png Binary files differnew file mode 100644 index 000000000..27cda9e9d --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_on_2_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_cast_on_light.png b/core/src/main/res/drawable-hdpi/ic_cast_on_light.png Binary files differnew file mode 100644 index 000000000..4ee525875 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_cast_on_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_close_light.png b/core/src/main/res/drawable-hdpi/ic_close_light.png Binary files differnew file mode 100644 index 000000000..93187e450 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_close_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_media_cast_disconnect.png b/core/src/main/res/drawable-hdpi/ic_media_cast_disconnect.png Binary files differnew file mode 100644 index 000000000..700c116e5 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_media_cast_disconnect.png diff --git a/core/src/main/res/drawable-hdpi/ic_pause_light.png b/core/src/main/res/drawable-hdpi/ic_pause_light.png Binary files differnew file mode 100644 index 000000000..0c505d1c8 --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_pause_light.png diff --git a/core/src/main/res/drawable-hdpi/ic_play_light.png b/core/src/main/res/drawable-hdpi/ic_play_light.png Binary files differnew file mode 100644 index 000000000..7957dff5b --- /dev/null +++ b/core/src/main/res/drawable-hdpi/ic_play_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_audiotrack_light.png b/core/src/main/res/drawable-mdpi/ic_audiotrack_light.png Binary files differnew file mode 100644 index 000000000..8ce237e38 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_audiotrack_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_disabled_light.png b/core/src/main/res/drawable-mdpi/ic_cast_disabled_light.png Binary files differnew file mode 100644 index 000000000..7940a0332 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_disabled_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_disconnect_grey600_36dp.png b/core/src/main/res/drawable-mdpi/ic_cast_disconnect_grey600_36dp.png Binary files differnew file mode 100644 index 000000000..b66b17e44 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_disconnect_grey600_36dp.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_disconnect_white_36dp.png b/core/src/main/res/drawable-mdpi/ic_cast_disconnect_white_36dp.png Binary files differnew file mode 100644 index 000000000..4f3c9a9ef --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_disconnect_white_36dp.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_light.png b/core/src/main/res/drawable-mdpi/ic_cast_light.png Binary files differnew file mode 100644 index 000000000..1f5bec20b --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_off_light.png b/core/src/main/res/drawable-mdpi/ic_cast_off_light.png Binary files differnew file mode 100644 index 000000000..963db27d4 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_off_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_on_0_light.png b/core/src/main/res/drawable-mdpi/ic_cast_on_0_light.png Binary files differnew file mode 100644 index 000000000..a90d9e305 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_on_0_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_on_1_light.png b/core/src/main/res/drawable-mdpi/ic_cast_on_1_light.png Binary files differnew file mode 100644 index 000000000..bb2cf30bf --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_on_1_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_on_2_light.png b/core/src/main/res/drawable-mdpi/ic_cast_on_2_light.png Binary files differnew file mode 100644 index 000000000..3ed59e55b --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_on_2_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_cast_on_light.png b/core/src/main/res/drawable-mdpi/ic_cast_on_light.png Binary files differnew file mode 100644 index 000000000..713427b97 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_cast_on_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_close_light.png b/core/src/main/res/drawable-mdpi/ic_close_light.png Binary files differnew file mode 100644 index 000000000..2c52c9b0f --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_close_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_media_cast_disconnect.png b/core/src/main/res/drawable-mdpi/ic_media_cast_disconnect.png Binary files differnew file mode 100644 index 000000000..767f420df --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_media_cast_disconnect.png diff --git a/core/src/main/res/drawable-mdpi/ic_pause_light.png b/core/src/main/res/drawable-mdpi/ic_pause_light.png Binary files differnew file mode 100644 index 000000000..6218a774f --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_pause_light.png diff --git a/core/src/main/res/drawable-mdpi/ic_play_light.png b/core/src/main/res/drawable-mdpi/ic_play_light.png Binary files differnew file mode 100644 index 000000000..1e0ccaf80 --- /dev/null +++ b/core/src/main/res/drawable-mdpi/ic_play_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_audiotrack_light.png b/core/src/main/res/drawable-xhdpi/ic_audiotrack_light.png Binary files differnew file mode 100644 index 000000000..247df6f39 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_audiotrack_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_disabled_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_disabled_light.png Binary files differnew file mode 100644 index 000000000..fbb3e062c --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_disabled_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_grey600_36dp.png b/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_grey600_36dp.png Binary files differnew file mode 100644 index 000000000..d81267f09 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_grey600_36dp.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_white_36dp.png b/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_white_36dp.png Binary files differnew file mode 100644 index 000000000..9aab16855 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_disconnect_white_36dp.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_light.png Binary files differnew file mode 100644 index 000000000..f2713e20e --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_off_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_off_light.png Binary files differnew file mode 100644 index 000000000..f4f8aaea8 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_off_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_on_0_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_on_0_light.png Binary files differnew file mode 100644 index 000000000..247fc95ba --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_on_0_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_on_1_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_on_1_light.png Binary files differnew file mode 100644 index 000000000..ecf4b4723 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_on_1_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_on_2_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_on_2_light.png Binary files differnew file mode 100644 index 000000000..60e3afa5d --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_on_2_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_cast_on_light.png b/core/src/main/res/drawable-xhdpi/ic_cast_on_light.png Binary files differnew file mode 100644 index 000000000..40ce9d4f2 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_cast_on_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_close_light.png b/core/src/main/res/drawable-xhdpi/ic_close_light.png Binary files differnew file mode 100644 index 000000000..49faa429a --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_close_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_media_cast_disconnect.png b/core/src/main/res/drawable-xhdpi/ic_media_cast_disconnect.png Binary files differnew file mode 100644 index 000000000..740867129 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_media_cast_disconnect.png diff --git a/core/src/main/res/drawable-xhdpi/ic_pause_light.png b/core/src/main/res/drawable-xhdpi/ic_pause_light.png Binary files differnew file mode 100644 index 000000000..40cd79f14 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_pause_light.png diff --git a/core/src/main/res/drawable-xhdpi/ic_play_light.png b/core/src/main/res/drawable-xhdpi/ic_play_light.png Binary files differnew file mode 100644 index 000000000..33f6a5919 --- /dev/null +++ b/core/src/main/res/drawable-xhdpi/ic_play_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_audiotrack_light.png b/core/src/main/res/drawable-xxhdpi/ic_audiotrack_light.png Binary files differnew file mode 100644 index 000000000..8c83b169d --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_audiotrack_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_disabled_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_disabled_light.png Binary files differnew file mode 100644 index 000000000..e94df3889 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_disabled_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_grey600_36dp.png b/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_grey600_36dp.png Binary files differnew file mode 100644 index 000000000..68f7650a1 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_grey600_36dp.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_white_36dp.png b/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_white_36dp.png Binary files differnew file mode 100644 index 000000000..828755407 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_disconnect_white_36dp.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_light.png Binary files differnew file mode 100644 index 000000000..c5722a6eb --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_off_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_off_light.png Binary files differnew file mode 100644 index 000000000..92ac67b34 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_off_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_on_0_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_on_0_light.png Binary files differnew file mode 100644 index 000000000..2742fcb4a --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_on_0_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_on_1_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_on_1_light.png Binary files differnew file mode 100644 index 000000000..405178e64 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_on_1_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_on_2_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_on_2_light.png Binary files differnew file mode 100644 index 000000000..dfe52428d --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_on_2_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_cast_on_light.png b/core/src/main/res/drawable-xxhdpi/ic_cast_on_light.png Binary files differnew file mode 100644 index 000000000..7e69a0864 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_cast_on_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_close_light.png b/core/src/main/res/drawable-xxhdpi/ic_close_light.png Binary files differnew file mode 100644 index 000000000..be519bfcb --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_close_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_media_cast_disconnect.png b/core/src/main/res/drawable-xxhdpi/ic_media_cast_disconnect.png Binary files differnew file mode 100644 index 000000000..2d2ec9035 --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_media_cast_disconnect.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_pause_light.png b/core/src/main/res/drawable-xxhdpi/ic_pause_light.png Binary files differnew file mode 100644 index 000000000..a36d4d11e --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_pause_light.png diff --git a/core/src/main/res/drawable-xxhdpi/ic_play_light.png b/core/src/main/res/drawable-xxhdpi/ic_play_light.png Binary files differnew file mode 100644 index 000000000..b1424874a --- /dev/null +++ b/core/src/main/res/drawable-xxhdpi/ic_play_light.png diff --git a/core/src/main/res/drawable-xxxhdpi/ic_close_light.png b/core/src/main/res/drawable-xxxhdpi/ic_close_light.png Binary files differnew file mode 100644 index 000000000..679c2a4d5 --- /dev/null +++ b/core/src/main/res/drawable-xxxhdpi/ic_close_light.png diff --git a/core/src/main/res/drawable-xxxhdpi/ic_pause_light.png b/core/src/main/res/drawable-xxxhdpi/ic_pause_light.png Binary files differnew file mode 100644 index 000000000..7de2ef4ed --- /dev/null +++ b/core/src/main/res/drawable-xxxhdpi/ic_pause_light.png diff --git a/core/src/main/res/drawable-xxxhdpi/ic_play_light.png b/core/src/main/res/drawable-xxxhdpi/ic_play_light.png Binary files differnew file mode 100644 index 000000000..4428c8477 --- /dev/null +++ b/core/src/main/res/drawable-xxxhdpi/ic_play_light.png diff --git a/core/src/main/res/values/attrs.xml b/core/src/main/res/values/attrs.xml index c3ee4d3e2..04ad7ea64 100644 --- a/core/src/main/res/values/attrs.xml +++ b/core/src/main/res/values/attrs.xml @@ -50,6 +50,7 @@ <attr name="ic_sort" format="reference"/> <attr name="ic_sd_storage" format="reference"/> <attr name="ic_create_new_folder" format="reference"/> + <attr name="ic_cast_disconnect" format="reference"/> <!-- Used in itemdescription --> <attr name="non_transparent_background" format="reference"/> diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 9026e2129..4b0f15df6 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -403,6 +403,8 @@ <string name="pref_faq">FAQ</string> <string name="pref_known_issues">Known issues</string> <string name="pref_no_browser_found">No web browser found.</string> + <string name="pref_cast_title">Cast support</string> + <string name="pref_cast_message">Enable support for remote media playback on Cast devices (such as Chromecast, Audio Speakers or Android TV)</string> <!-- Auto-Flattr dialog --> <string name="auto_flattr_enable">Enable automatic flattring</string> @@ -614,4 +616,20 @@ <string name="proxy_host_invalid_error">Host is not a valid IP address or domain</string> <string name="proxy_port_invalid_error">Port not valid</string> + <!-- Casting --> + <string name="cast_media_route_menu_title">Play on…</string> + <string name="cast_disconnect_label">Disconnect the cast session</string> + <string name="cast_not_castable">Media selected is not compatible with cast device</string> + <string name="cast_failed_to_play">Failed to start the playback of media</string> + <string name="cast_failed_to_stop">Failed to stop the playback of media</string> + <string name="cast_failed_to_pause">Failed to pause the playback of media</string> + <!--<string name="cast_failed_to_connect">Could not connect to the device</string>--> + <string name="cast_failed_setting_volume">Failed to set the volume</string> + <string name="cast_failed_no_connection">No connection to the cast device is present</string> + <string name="cast_failed_no_connection_trans">Connection to the cast device has been lost. Application is trying to re-establish the connection, if possible. Please wait for a few seconds and try again.</string> + <string name="cast_failed_perform_action">Failed to perform the action</string> + <string name="cast_failed_status_request">Failed to sync up with the cast device</string> + <string name="cast_failed_seek">Failed to seek to the new position on the cast device</string> + <string name="cast_failed_receiver_player_error">Receiver player has encountered a severe error</string> + <string name="cast_failed_media_error_skipping">Error playing media. Skipping…</string> </resources> diff --git a/core/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml index f7775a0bf..6a4dc4781 100644 --- a/core/src/main/res/values/styles.xml +++ b/core/src/main/res/values/styles.xml @@ -57,6 +57,7 @@ <item name="attr/ic_sort">@drawable/ic_sort_grey600_24dp</item> <item name="attr/ic_sd_storage">@drawable/ic_sd_storage_grey600_36dp</item> <item name="attr/ic_create_new_folder">@drawable/ic_create_new_folder_grey600_24dp</item> + <item name="attr/ic_cast_disconnect">@drawable/ic_cast_disconnect_grey600_36dp</item> </style> <style name="Theme.AntennaPod.Dark" parent="Theme.AppCompat"> @@ -115,6 +116,7 @@ <item name="attr/ic_sort">@drawable/ic_sort_white_24dp</item> <item name="attr/ic_sd_storage">@drawable/ic_sd_storage_white_36dp</item> <item name="attr/ic_create_new_folder">@drawable/ic_create_new_folder_white_24dp</item> + <item name="attr/ic_cast_disconnect">@drawable/ic_cast_disconnect_white_36dp</item> </style> <style name="Theme.AntennaPod.Light.NoTitle" parent="Theme.AppCompat.Light.NoActionBar"> @@ -174,6 +176,7 @@ <item name="attr/ic_sort">@drawable/ic_sort_grey600_24dp</item> <item name="attr/ic_sd_storage">@drawable/ic_sd_storage_grey600_36dp</item> <item name="attr/ic_create_new_folder">@drawable/ic_create_new_folder_grey600_24dp</item> + <item name="attr/ic_cast_disconnect">@drawable/ic_cast_disconnect_grey600_36dp</item> </style> <style name="Theme.AntennaPod.Dark.NoTitle" parent="Theme.AppCompat.NoActionBar"> @@ -233,6 +236,7 @@ <item name="attr/ic_sort">@drawable/ic_sort_white_24dp</item> <item name="attr/ic_sd_storage">@drawable/ic_sd_storage_white_36dp</item> <item name="attr/ic_create_new_folder">@drawable/ic_create_new_folder_white_24dp</item> + <item name="attr/ic_cast_disconnect">@drawable/ic_cast_disconnect_white_36dp</item> </style> <style name="Theme.AntennaPod.VideoPlayer" parent="@style/Theme.AntennaPod.Dark"> |