summaryrefslogtreecommitdiff
path: root/playback
diff options
context:
space:
mode:
authorByteHamster <ByteHamster@users.noreply.github.com>2024-03-29 21:05:02 +0100
committerGitHub <noreply@github.com>2024-03-29 21:05:02 +0100
commit8accb546850e5d66aaab310c4cd4a528c058386e (patch)
treecc6ea8bd7304db5d2dae0467bae04d491137cef2 /playback
parent2fd73b148d012fba7308c86494689103b8aaace4 (diff)
downloadAntennaPod-8accb546850e5d66aaab310c4cd4a528c058386e.zip
Move playback service to module (#7042)
Diffstat (limited to 'playback')
-rw-r--r--playback/service/README.md3
-rw-r--r--playback/service/build.gradle44
-rw-r--r--playback/service/src/main/AndroidManifest.xml66
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/MediaButtonReceiver.java44
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackController.java478
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackService.java2003
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceInterface.java20
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceStarter.java47
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackStatus.java21
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/QuickSettingsTileService.java60
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ExoPlayerWrapper.java408
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/LocalPSMP.java774
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlayableUtils.java35
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceNotificationBuilder.java274
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceStateManager.java52
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceTaskManager.java365
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackVolumeUpdater.java38
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ShakeListener.java62
-rw-r--r--playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/WearMediaSession.java24
-rw-r--r--playback/service/src/main/res/values/ids.xml4
-rw-r--r--playback/service/src/test/java/de/danoeh/antennapod/playback/service/PlaybackVolumeUpdaterTest.java228
21 files changed, 5050 insertions, 0 deletions
diff --git a/playback/service/README.md b/playback/service/README.md
new file mode 100644
index 000000000..ed6864d04
--- /dev/null
+++ b/playback/service/README.md
@@ -0,0 +1,3 @@
+# :playback:service
+
+The main service doing media playback.
diff --git a/playback/service/build.gradle b/playback/service/build.gradle
new file mode 100644
index 000000000..e49052c44
--- /dev/null
+++ b/playback/service/build.gradle
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../common.gradle"
+apply from: "../../playFlavor.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.playback.service"
+}
+
+dependencies {
+ implementation project(':core')
+ implementation project(':event')
+ implementation project(':model')
+ implementation project(':net:common')
+ implementation project(':net:sync:service-interface')
+ implementation project(':playback:base')
+ implementation project(':playback:cast')
+ implementation project(':storage:database')
+ implementation project(':storage:preferences')
+ implementation project(':ui:app-start-intent')
+ implementation project(':ui:common')
+ implementation project(':ui:episodes')
+ implementation project(':ui:i18n')
+ implementation project(':ui:notifications')
+ implementation project(':ui:widget')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.core:core:$coreVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation "androidx.media:media:$mediaVersion"
+ implementation "androidx.media3:media3-datasource-okhttp:$media3Version"
+ implementation "androidx.media3:media3-exoplayer:$media3Version"
+ implementation "androidx.media3:media3-ui:$media3Version"
+
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ implementation "com.github.bumptech.glide:glide:$glideVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+
+ testImplementation "junit:junit:$junitVersion"
+ testImplementation 'org.mockito:mockito-core:5.11.0'
+}
diff --git a/playback/service/src/main/AndroidManifest.xml b/playback/service/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..62ab1094e
--- /dev/null
+++ b/playback/service/src/main/AndroidManifest.xml
@@ -0,0 +1,66 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ tools:ignore="ScopedStorage" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+ <uses-permission android:name="android.permission.BLUETOOTH" />
+ <uses-permission android:name="android.permission.VIBRATE" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+ <application
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:supportsRtl="true">
+
+ <service android:name="de.danoeh.antennapod.playback.service.PlaybackService"
+ android:label="@string/app_name"
+ android:enabled="true"
+ android:exported="true"
+ android:foregroundServiceType="mediaPlayback"
+ tools:ignore="ExportedService">
+
+ <intent-filter>
+ <action android:name="android.media.browse.MediaBrowserService"/>
+ <action android:name="de.danoeh.antennapod.intents.PLAYBACK_SERVICE" />
+ </intent-filter>
+ </service>
+
+ <receiver
+ android:name="de.danoeh.antennapod.playback.service.MediaButtonReceiver"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MEDIA_BUTTON" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="de.danoeh.antennapod.NOTIFY_BUTTON_RECEIVER" />
+ </intent-filter>
+ </receiver>
+
+ <service
+ android:name="de.danoeh.antennapod.playback.service.QuickSettingsTileService"
+ android:enabled="true"
+ android:exported="true"
+ android:label="@string/app_name"
+ android:icon="@drawable/ic_notification"
+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
+ <intent-filter>
+ <action android:name="android.service.quicksettings.action.QS_TILE" />
+ </intent-filter>
+ <meta-data android:name="android.service.quicksettings.ACTIVE_TILE" android:value="true" />
+ <meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE" android:value="true" />
+ </service>
+ </application>
+
+ <queries>
+ <intent>
+ <action android:name="android.intent.action.VIEW" />
+ <data android:scheme="https" />
+ </intent>
+ </queries>
+
+</manifest>
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/MediaButtonReceiver.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/MediaButtonReceiver.java
new file mode 100644
index 000000000..13af6bf69
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/MediaButtonReceiver.java
@@ -0,0 +1,44 @@
+package de.danoeh.antennapod.playback.service;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import androidx.core.content.ContextCompat;
+import android.util.Log;
+import android.view.KeyEvent;
+
+/**
+ * Receives media button events.
+ */
+public class MediaButtonReceiver extends BroadcastReceiver {
+ private static final String TAG = "MediaButtonReceiver";
+ public static final String EXTRA_KEYCODE = "de.danoeh.antennapod.core.service.extra.MediaButtonReceiver.KEYCODE";
+ public static final String EXTRA_CUSTOM_ACTION =
+ "de.danoeh.antennapod.core.service.extra.MediaButtonReceiver.CUSTOM_ACTION";
+ public static final String EXTRA_SOURCE = "de.danoeh.antennapod.core.service.extra.MediaButtonReceiver.SOURCE";
+ public static final String EXTRA_HARDWAREBUTTON
+ = "de.danoeh.antennapod.core.service.extra.MediaButtonReceiver.HARDWAREBUTTON";
+ public static final String PLAYBACK_SERVICE_INTENT = "de.danoeh.antennapod.intents.PLAYBACK_SERVICE";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.d(TAG, "Received intent");
+ if (intent == null || intent.getExtras() == null) {
+ return;
+ }
+ KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
+ if (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
+ Intent serviceIntent = new Intent(PLAYBACK_SERVICE_INTENT);
+ serviceIntent.setPackage(context.getPackageName());
+ serviceIntent.putExtra(EXTRA_KEYCODE, event.getKeyCode());
+ serviceIntent.putExtra(EXTRA_SOURCE, event.getSource());
+ serviceIntent.putExtra(EXTRA_HARDWAREBUTTON, event.getEventTime() > 0 || event.getDownTime() > 0);
+ try {
+ ContextCompat.startForegroundService(context, serviceIntent);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackController.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackController.java
new file mode 100644
index 000000000..50647b5dd
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackController.java
@@ -0,0 +1,478 @@
+package de.danoeh.antennapod.playback.service;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.ServiceConnection;
+import android.os.IBinder;
+import android.util.Log;
+import android.util.Pair;
+import android.view.SurfaceHolder;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.event.playback.PlaybackServiceEvent;
+import de.danoeh.antennapod.event.playback.SpeedChangedEvent;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+import de.danoeh.antennapod.ui.episodes.PlaybackSpeedUtils;
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Communicates with the playback service. GUI classes should use this class to
+ * control playback instead of communicating with the PlaybackService directly.
+ */
+public abstract class PlaybackController {
+
+ private static final String TAG = "PlaybackController";
+
+ private final Activity activity;
+ private PlaybackService playbackService;
+ private Playable media;
+ private PlayerStatus status = PlayerStatus.STOPPED;
+
+ private boolean mediaInfoLoaded = false;
+ private boolean released = false;
+ private boolean initialized = false;
+ private boolean eventsRegistered = false;
+ private long loadedFeedMedia = -1;
+
+ public PlaybackController(@NonNull Activity activity) {
+ this.activity = activity;
+ }
+
+ /**
+ * Creates a new connection to the playbackService.
+ */
+ public synchronized void init() {
+ if (!eventsRegistered) {
+ EventBus.getDefault().register(this);
+ eventsRegistered = true;
+ }
+ if (PlaybackService.isRunning) {
+ initServiceRunning();
+ } else {
+ updatePlayButtonShowsPlay(true);
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ public void onEventMainThread(PlaybackServiceEvent event) {
+ if (event.action == PlaybackServiceEvent.Action.SERVICE_STARTED) {
+ init();
+ }
+ }
+
+ private synchronized void initServiceRunning() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ activity.registerReceiver(statusUpdate, new IntentFilter(
+ PlaybackService.ACTION_PLAYER_STATUS_CHANGED));
+ activity.registerReceiver(notificationReceiver, new IntentFilter(
+ PlaybackServiceInterface.ACTION_PLAYER_NOTIFICATION));
+
+ if (!released) {
+ bindToService();
+ } else {
+ throw new IllegalStateException("Can't call init() after release() has been called");
+ }
+ checkMediaInfoLoaded();
+ }
+
+ /**
+ * Should be called if the PlaybackController is no longer needed, for
+ * example in the activity's onStop() method.
+ */
+ public void release() {
+ Log.d(TAG, "Releasing PlaybackController");
+
+ try {
+ activity.unregisterReceiver(statusUpdate);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+
+ try {
+ activity.unregisterReceiver(notificationReceiver);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+ unbind();
+ media = null;
+ released = true;
+
+ if (eventsRegistered) {
+ EventBus.getDefault().unregister(this);
+ eventsRegistered = false;
+ }
+ }
+
+ private void unbind() {
+ try {
+ activity.unbindService(mConnection);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+ initialized = false;
+ }
+
+ /**
+ * Should be called in the activity's onPause() method.
+ */
+ public void pause() {
+ mediaInfoLoaded = false;
+ }
+
+ /**
+ * Tries to establish a connection to the PlaybackService. If it isn't
+ * running, the PlaybackService will be started with the last played media
+ * as the arguments of the launch intent.
+ */
+ private void bindToService() {
+ Log.d(TAG, "Trying to connect to service");
+ if (!PlaybackService.isRunning) {
+ throw new IllegalStateException("Trying to bind but service is not running");
+ }
+ boolean bound = activity.bindService(new Intent(activity, PlaybackService.class), mConnection, 0);
+ Log.d(TAG, "Result for service binding: " + bound);
+ }
+
+ private final ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ if(service instanceof PlaybackService.LocalBinder) {
+ playbackService = ((PlaybackService.LocalBinder) service).getService();
+ if (!released) {
+ queryService();
+ Log.d(TAG, "Connection to Service established");
+ } else {
+ Log.i(TAG, "Connection to playback service has been established, " +
+ "but controller has already been released");
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ playbackService = null;
+ initialized = false;
+ Log.d(TAG, "Disconnected from Service");
+ }
+ };
+
+ private final BroadcastReceiver statusUpdate = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.d(TAG, "Received statusUpdate Intent.");
+ if (playbackService != null) {
+ PlaybackServiceMediaPlayer.PSMPInfo info = playbackService.getPSMPInfo();
+ status = info.playerStatus;
+ media = info.playable;
+ handleStatus();
+ } else {
+ Log.w(TAG, "Couldn't receive status update: playbackService was null");
+ if (PlaybackService.isRunning) {
+ bindToService();
+ } else {
+ status = PlayerStatus.STOPPED;
+ handleStatus();
+ }
+ }
+ }
+ };
+
+ private final BroadcastReceiver notificationReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ int type = intent.getIntExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_TYPE, -1);
+ int code = intent.getIntExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_CODE, -1);
+ if (code == -1 || type == -1) {
+ Log.d(TAG, "Bad arguments. Won't handle intent");
+ return;
+ }
+ switch (type) {
+ case PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD:
+ if (playbackService == null && PlaybackService.isRunning) {
+ bindToService();
+ return;
+ }
+ mediaInfoLoaded = false;
+ queryService();
+ break;
+ case PlaybackServiceInterface.NOTIFICATION_TYPE_PLAYBACK_END:
+ onPlaybackEnd();
+ break;
+ }
+ }
+
+ };
+
+ public void onPlaybackEnd() {}
+
+ /**
+ * 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() {
+ Log.d(TAG, "status: " + status.toString());
+ checkMediaInfoLoaded();
+ switch (status) {
+ case PLAYING:
+ updatePlayButtonShowsPlay(false);
+ break;
+ case PREPARING:
+ if (playbackService != null) {
+ updatePlayButtonShowsPlay(!playbackService.isStartWhenPrepared());
+ }
+ break;
+ case PAUSED:
+ case PREPARED: // Fall-through
+ case STOPPED: // Fall-through
+ case INITIALIZED: // Fall-through
+ updatePlayButtonShowsPlay(true);
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void checkMediaInfoLoaded() {
+ if (!mediaInfoLoaded || loadedFeedMedia != PlaybackPreferences.getCurrentlyPlayingFeedMediaId()) {
+ loadedFeedMedia = PlaybackPreferences.getCurrentlyPlayingFeedMediaId();
+ loadMediaInfo();
+ }
+ mediaInfoLoaded = true;
+ }
+
+ protected void updatePlayButtonShowsPlay(boolean showPlay) {
+
+ }
+
+ public abstract void loadMediaInfo();
+
+ /**
+ * Called when connection to playback service has been established or
+ * information has to be refreshed
+ */
+ private void queryService() {
+ Log.d(TAG, "Querying service info");
+ if (playbackService != null) {
+ PlaybackServiceMediaPlayer.PSMPInfo info = playbackService.getPSMPInfo();
+ status = info.playerStatus;
+ media = info.playable;
+
+ // make sure that new media is loaded if it's available
+ mediaInfoLoaded = false;
+ handleStatus();
+
+ } else {
+ Log.e(TAG,
+ "queryService() was called without an existing connection to playbackservice");
+ }
+ }
+
+ public void playPause() {
+ if (playbackService == null) {
+ new PlaybackServiceStarter(activity, media).start();
+ Log.w(TAG, "Play/Pause button was pressed, but playbackservice was null!");
+ return;
+ }
+ switch (status) {
+ case PLAYING:
+ playbackService.pause(true, false);
+ break;
+ case PAUSED:
+ case PREPARED:
+ playbackService.resume();
+ break;
+ case PREPARING:
+ playbackService.setStartWhenPrepared(!playbackService.isStartWhenPrepared());
+ break;
+ case INITIALIZED:
+ playbackService.setStartWhenPrepared(true);
+ playbackService.prepare();
+ break;
+ default:
+ new PlaybackServiceStarter(activity, media)
+ .callEvenIfRunning(true)
+ .start();
+ Log.w(TAG, "Play/Pause button was pressed and PlaybackService state was unknown");
+ break;
+ }
+ }
+
+ public int getPosition() {
+ if (playbackService != null) {
+ return playbackService.getCurrentPosition();
+ } else if (getMedia() != null) {
+ return getMedia().getPosition();
+ } else {
+ return Playable.INVALID_TIME;
+ }
+ }
+
+ public int getDuration() {
+ if (playbackService != null) {
+ return playbackService.getDuration();
+ } else if (getMedia() != null) {
+ return getMedia().getDuration();
+ } else {
+ return Playable.INVALID_TIME;
+ }
+ }
+
+ public Playable getMedia() {
+ if (media == null) {
+ media = DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId());
+ }
+ return media;
+ }
+
+ public boolean sleepTimerActive() {
+ return playbackService != null && playbackService.sleepTimerActive();
+ }
+
+ public void disableSleepTimer() {
+ if (playbackService != null) {
+ playbackService.disableSleepTimer();
+ }
+ }
+
+ public long getSleepTimerTimeLeft() {
+ if (playbackService != null) {
+ return playbackService.getSleepTimerTimeLeft();
+ } else {
+ return Playable.INVALID_TIME;
+ }
+ }
+
+ public void extendSleepTimer(long extendTime) {
+ long timeLeft = getSleepTimerTimeLeft();
+ if (playbackService != null && timeLeft != Playable.INVALID_TIME) {
+ setSleepTimer(timeLeft + extendTime);
+ }
+ }
+
+ public void setSleepTimer(long time) {
+ if (playbackService != null) {
+ playbackService.setSleepTimer(time);
+ }
+ }
+
+ public void seekTo(int time) {
+ if (playbackService != null) {
+ playbackService.seekTo(time);
+ } else if (getMedia() instanceof FeedMedia) {
+ FeedMedia media = (FeedMedia) getMedia();
+ media.setPosition(time);
+ DBWriter.setFeedItem(media.getItem());
+ EventBus.getDefault().post(new PlaybackPositionEvent(time, getMedia().getDuration()));
+ }
+ }
+
+ public void setVideoSurface(SurfaceHolder holder) {
+ if (playbackService != null) {
+ playbackService.setVideoSurface(holder);
+ }
+ }
+
+ public PlayerStatus getStatus() {
+ return status;
+ }
+
+ public void setPlaybackSpeed(float speed) {
+ if (playbackService != null) {
+ playbackService.setSpeed(speed);
+ } else {
+ EventBus.getDefault().post(new SpeedChangedEvent(speed));
+ }
+ }
+
+ public void setSkipSilence(boolean skipSilence) {
+ if (playbackService != null) {
+ playbackService.setSkipSilence(skipSilence);
+ }
+ }
+
+ public float getCurrentPlaybackSpeedMultiplier() {
+ if (playbackService != null) {
+ return playbackService.getCurrentPlaybackSpeed();
+ } else {
+ return PlaybackSpeedUtils.getCurrentPlaybackSpeed(getMedia());
+ }
+ }
+
+ public boolean getCurrentPlaybackSkipSilence() {
+ if (playbackService != null) {
+ return playbackService.getCurrentSkipSilence();
+ } else {
+ return PlaybackSpeedUtils.getCurrentSkipSilencePreference(getMedia())
+ == FeedPreferences.SkipSilence.AGGRESSIVE;
+ }
+ }
+
+ public List<String> getAudioTracks() {
+ if (playbackService == null) {
+ return Collections.emptyList();
+ }
+ return playbackService.getAudioTracks();
+ }
+
+ public int getSelectedAudioTrack() {
+ if (playbackService == null) {
+ return -1;
+ }
+ return playbackService.getSelectedAudioTrack();
+ }
+
+ public void setAudioTrack(int track) {
+ if (playbackService != null) {
+ playbackService.setAudioTrack(track);
+ }
+ }
+
+ public boolean isPlayingVideoLocally() {
+ if (PlaybackService.isCasting()) {
+ return false;
+ } else if (playbackService != null) {
+ return PlaybackService.getCurrentMediaType() == MediaType.VIDEO;
+ } else {
+ return getMedia() != null && getMedia().getMediaType() == MediaType.VIDEO;
+ }
+ }
+
+ public Pair<Integer, Integer> getVideoSize() {
+ if (playbackService != null) {
+ return playbackService.getVideoSize();
+ } else {
+ return null;
+ }
+ }
+
+ public void notifyVideoSurfaceAbandoned() {
+ if (playbackService != null) {
+ playbackService.notifyVideoSurfaceAbandoned();
+ }
+ }
+
+ public boolean isStreaming() {
+ return playbackService != null && playbackService.isStreaming();
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackService.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackService.java
new file mode 100644
index 000000000..138c9bc61
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackService.java
@@ -0,0 +1,2003 @@
+package de.danoeh.antennapod.playback.service;
+
+import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.app.UiModeManager;
+import android.bluetooth.BluetoothA2dp;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Vibrator;
+import android.service.quicksettings.TileService;
+import android.support.v4.media.MediaBrowserCompat;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.MediaMetadataCompat;
+import android.support.v4.media.session.MediaSessionCompat;
+import android.support.v4.media.session.PlaybackStateCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
+import android.view.KeyEvent;
+import android.view.SurfaceHolder;
+import android.view.ViewConfiguration;
+import android.webkit.URLUtil;
+import android.widget.Toast;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+import androidx.media.MediaBrowserServiceCompat;
+
+import de.danoeh.antennapod.event.PlayerStatusEvent;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
+import de.danoeh.antennapod.playback.service.internal.LocalPSMP;
+import de.danoeh.antennapod.playback.service.internal.PlayableUtils;
+import de.danoeh.antennapod.playback.service.internal.PlaybackServiceNotificationBuilder;
+import de.danoeh.antennapod.playback.service.internal.PlaybackServiceStateManager;
+import de.danoeh.antennapod.playback.service.internal.PlaybackServiceTaskManager;
+import de.danoeh.antennapod.playback.service.internal.PlaybackVolumeUpdater;
+import de.danoeh.antennapod.playback.service.internal.WearMediaSession;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import de.danoeh.antennapod.ui.widget.WidgetUpdater;
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.playback.service.internal.PlaybackServiceTaskManager.SleepTimer;
+import de.danoeh.antennapod.core.util.ChapterUtils;
+import de.danoeh.antennapod.core.util.FeedUtil;
+import de.danoeh.antennapod.core.util.IntentUtils;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.event.PlayerErrorEvent;
+import de.danoeh.antennapod.event.playback.BufferUpdateEvent;
+import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
+import de.danoeh.antennapod.event.playback.PlaybackServiceEvent;
+import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
+import de.danoeh.antennapod.event.settings.SkipIntroEndingChangedEvent;
+import de.danoeh.antennapod.event.settings.SpeedPresetChangedEvent;
+import de.danoeh.antennapod.event.settings.VolumeAdaptionChangedEvent;
+import de.danoeh.antennapod.model.feed.Chapter;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+import de.danoeh.antennapod.playback.cast.CastPsmp;
+import de.danoeh.antennapod.playback.cast.CastStateListener;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
+import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter;
+import io.reactivex.Completable;
+import io.reactivex.Observable;
+import io.reactivex.Single;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+/**
+ * Controls the MediaPlayer that plays a FeedMedia-file
+ */
+public class PlaybackService extends MediaBrowserServiceCompat {
+ /**
+ * Logging tag
+ */
+ private static final String TAG = "PlaybackService";
+
+ public static final String ACTION_PLAYER_STATUS_CHANGED = "action.de.danoeh.antennapod.core.service.playerStatusChanged";
+ private static final String AVRCP_ACTION_PLAYER_STATUS_CHANGED = "com.android.music.playstatechanged";
+ private static final String AVRCP_ACTION_META_CHANGED = "com.android.music.metachanged";
+
+ /**
+ * Custom actions used by Android Wear, Android Auto, and Android (API 33+ only)
+ */
+ private static final String CUSTOM_ACTION_SKIP_TO_NEXT = "action.de.danoeh.antennapod.core.service.skipToNext";
+ private static final String CUSTOM_ACTION_FAST_FORWARD = "action.de.danoeh.antennapod.core.service.fastForward";
+ private static final String CUSTOM_ACTION_REWIND = "action.de.danoeh.antennapod.core.service.rewind";
+ private static final String CUSTOM_ACTION_CHANGE_PLAYBACK_SPEED =
+ "action.de.danoeh.antennapod.core.service.changePlaybackSpeed";
+ private static final String CUSTOM_ACTION_TOGGLE_SLEEP_TIMER =
+ "action.de.danoeh.antennapod.core.service.toggleSleepTimer";
+ public static final String CUSTOM_ACTION_NEXT_CHAPTER = "action.de.danoeh.antennapod.core.service.next_chapter";
+
+ /**
+ * Set a max number of episodes to load for Android Auto, otherwise there could be performance issues
+ */
+ public static final int MAX_ANDROID_AUTO_EPISODES_PER_FEED = 100;
+
+ /**
+ * Is true if service is running.
+ */
+ public static boolean isRunning = false;
+ /**
+ * Is true if the service was running, but paused due to headphone disconnect
+ */
+ private static boolean transientPause = false;
+ /**
+ * Is true if a Cast Device is connected to the service.
+ */
+ private static volatile boolean isCasting = false;
+
+ private PlaybackServiceMediaPlayer mediaPlayer;
+ private PlaybackServiceTaskManager taskManager;
+ private PlaybackServiceStateManager stateManager;
+ private Disposable positionEventTimer;
+ private PlaybackServiceNotificationBuilder notificationBuilder;
+ private CastStateListener castStateListener;
+
+ private String autoSkippedFeedMediaId = null;
+ private int clickCount = 0;
+ private final Handler clickHandler = new Handler(Looper.getMainLooper());
+
+ /**
+ * Used for Lollipop notifications, Android Wear, and Android Auto.
+ */
+ private MediaSessionCompat mediaSession;
+
+ private static volatile MediaType currentMediaType = MediaType.UNKNOWN;
+
+ private final IBinder mBinder = new LocalBinder();
+
+ public class LocalBinder extends Binder {
+ public PlaybackService getService() {
+ return PlaybackService.this;
+ }
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ Log.d(TAG, "Received onUnbind event");
+ return super.onUnbind(intent);
+ }
+
+ /**
+ * Returns an intent which starts an audio- or videoplayer, depending on the
+ * type of media that is being played. If the playbackservice is not
+ * running, the type of the last played media will be looked up.
+ */
+ public static Intent getPlayerActivityIntent(Context context) {
+ boolean showVideoPlayer;
+
+ if (isRunning) {
+ showVideoPlayer = currentMediaType == MediaType.VIDEO && !isCasting;
+ } else {
+ showVideoPlayer = PlaybackPreferences.getCurrentEpisodeIsVideo();
+ }
+
+ if (showVideoPlayer) {
+ return new VideoPlayerActivityStarter(context).getIntent();
+ } else {
+ return new MainActivityStarter(context).withOpenPlayer().getIntent();
+ }
+ }
+
+ /**
+ * Same as {@link #getPlayerActivityIntent(Context)}, but here the type of activity
+ * depends on the FeedMedia that is provided as an argument.
+ */
+ public static Intent getPlayerActivityIntent(Context context, Playable media) {
+ if (media.getMediaType() == MediaType.VIDEO && !isCasting) {
+ return new VideoPlayerActivityStarter(context).getIntent();
+ } else {
+ return new MainActivityStarter(context).withOpenPlayer().getIntent();
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.d(TAG, "Service created.");
+ isRunning = true;
+
+ stateManager = new PlaybackServiceStateManager(this);
+ notificationBuilder = new PlaybackServiceNotificationBuilder(this);
+
+ registerReceiver(autoStateUpdated, new IntentFilter("com.google.android.gms.car.media.STATUS"));
+ registerReceiver(headsetDisconnected, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
+ registerReceiver(shutdownReceiver, new IntentFilter(PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+ registerReceiver(bluetoothStateUpdated, new IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED));
+ registerReceiver(audioBecomingNoisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
+ EventBus.getDefault().register(this);
+ taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback);
+
+ recreateMediaSessionIfNeeded();
+ castStateListener = new CastStateListener(this) {
+ @Override
+ public void onSessionStartedOrEnded() {
+ recreateMediaPlayer();
+ }
+ };
+ EventBus.getDefault().post(new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_STARTED));
+ }
+
+ void recreateMediaSessionIfNeeded() {
+ if (mediaSession != null) {
+ // Media session was not destroyed, so we can re-use it.
+ if (!mediaSession.isActive()) {
+ mediaSession.setActive(true);
+ }
+ return;
+ }
+ ComponentName eventReceiver = new ComponentName(getApplicationContext(), MediaButtonReceiver.class);
+ Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
+ mediaButtonIntent.setComponent(eventReceiver);
+ PendingIntent buttonReceiverIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0));
+
+ mediaSession = new MediaSessionCompat(getApplicationContext(), TAG, eventReceiver, buttonReceiverIntent);
+ setSessionToken(mediaSession.getSessionToken());
+
+ try {
+ mediaSession.setCallback(sessionCallback);
+ mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
+ } catch (NullPointerException npe) {
+ // on some devices (Huawei) setting active can cause a NullPointerException
+ // even with correct use of the api.
+ // See http://stackoverflow.com/questions/31556679/android-huawei-mediassessioncompat
+ // and https://plus.google.com/+IanLake/posts/YgdTkKFxz7d
+ Log.e(TAG, "NullPointerException while setting up MediaSession");
+ npe.printStackTrace();
+ }
+
+ recreateMediaPlayer();
+ mediaSession.setActive(true);
+ }
+
+ void recreateMediaPlayer() {
+ Playable media = null;
+ boolean wasPlaying = false;
+ if (mediaPlayer != null) {
+ media = mediaPlayer.getPlayable();
+ wasPlaying = mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING;
+ mediaPlayer.pause(true, false);
+ mediaPlayer.shutdown();
+ }
+ mediaPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback);
+ if (mediaPlayer == null) {
+ mediaPlayer = new LocalPSMP(this, mediaPlayerCallback); // Cast not supported or not connected
+ }
+ if (media != null) {
+ mediaPlayer.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true);
+ }
+ isCasting = mediaPlayer.isCasting();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ Log.d(TAG, "Service is about to be destroyed");
+
+ if (notificationBuilder.getPlayerStatus() == PlayerStatus.PLAYING) {
+ notificationBuilder.setPlayerStatus(PlayerStatus.STOPPED);
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_playing, notificationBuilder.build());
+ }
+ }
+ stateManager.stopForeground(!UserPreferences.isPersistNotify());
+ isRunning = false;
+ currentMediaType = MediaType.UNKNOWN;
+ castStateListener.destroy();
+
+ cancelPositionObserver();
+ if (mediaSession != null) {
+ mediaSession.release();
+ mediaSession = null;
+ }
+ unregisterReceiver(autoStateUpdated);
+ unregisterReceiver(headsetDisconnected);
+ unregisterReceiver(shutdownReceiver);
+ unregisterReceiver(bluetoothStateUpdated);
+ unregisterReceiver(audioBecomingNoisy);
+ mediaPlayer.shutdown();
+ taskManager.shutdown();
+ EventBus.getDefault().unregister(this);
+ }
+
+ @Override
+ public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, Bundle rootHints) {
+ Log.d(TAG, "OnGetRoot: clientPackageName=" + clientPackageName +
+ "; clientUid=" + clientUid + " ; rootHints=" + rootHints);
+ if (rootHints != null && rootHints.getBoolean(BrowserRoot.EXTRA_RECENT)) {
+ Bundle extras = new Bundle();
+ extras.putBoolean(BrowserRoot.EXTRA_RECENT, true);
+ Log.d(TAG, "OnGetRoot: Returning BrowserRoot " + R.string.current_playing_episode);
+ return new BrowserRoot(getResources().getString(R.string.current_playing_episode), extras);
+ }
+
+ // Name visible in Android Auto
+ return new BrowserRoot(getResources().getString(R.string.app_name), null);
+ }
+
+ private void loadQueueForMediaSession() {
+ Single.<List<MediaSessionCompat.QueueItem>>create(emitter -> {
+ List<MediaSessionCompat.QueueItem> queueItems = new ArrayList<>();
+ for (FeedItem feedItem : DBReader.getQueue()) {
+ if (feedItem.getMedia() != null) {
+ MediaDescriptionCompat mediaDescription = feedItem.getMedia().getMediaItem().getDescription();
+ queueItems.add(new MediaSessionCompat.QueueItem(mediaDescription, feedItem.getId()));
+ }
+ }
+ emitter.onSuccess(queueItems);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(queueItems -> mediaSession.setQueue(queueItems), Throwable::printStackTrace);
+ }
+
+ private MediaBrowserCompat.MediaItem createBrowsableMediaItem(
+ @StringRes int title, @DrawableRes int icon, int numEpisodes) {
+ Uri uri = new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(getResources().getResourcePackageName(icon))
+ .appendPath(getResources().getResourceTypeName(icon))
+ .appendPath(getResources().getResourceEntryName(icon))
+ .build();
+
+ MediaDescriptionCompat description = new MediaDescriptionCompat.Builder()
+ .setIconUri(uri)
+ .setMediaId(getResources().getString(title))
+ .setTitle(getResources().getString(title))
+ .setSubtitle(getResources().getQuantityString(R.plurals.num_episodes, numEpisodes, numEpisodes))
+ .build();
+ return new MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE);
+ }
+
+ private MediaBrowserCompat.MediaItem createBrowsableMediaItemForFeed(Feed feed) {
+ MediaDescriptionCompat.Builder builder = new MediaDescriptionCompat.Builder()
+ .setMediaId("FeedId:" + feed.getId())
+ .setTitle(feed.getTitle())
+ .setDescription(feed.getDescription())
+ .setSubtitle(feed.getCustomTitle());
+ if (feed.getImageUrl() != null) {
+ builder.setIconUri(Uri.parse(feed.getImageUrl()));
+ }
+ if (feed.getLink() != null) {
+ builder.setMediaUri(Uri.parse(feed.getLink()));
+ }
+ MediaDescriptionCompat description = builder.build();
+ return new MediaBrowserCompat.MediaItem(description,
+ MediaBrowserCompat.MediaItem.FLAG_BROWSABLE);
+ }
+
+ @Override
+ public void onLoadChildren(@NonNull String parentId,
+ @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
+ Log.d(TAG, "OnLoadChildren: parentMediaId=" + parentId);
+ result.detach();
+
+ Completable.create(emitter -> {
+ result.sendResult(loadChildrenSynchronous(parentId));
+ emitter.onComplete();
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ () -> {
+ }, e -> {
+ e.printStackTrace();
+ result.sendResult(null);
+ });
+ }
+
+ private List<MediaBrowserCompat.MediaItem> loadChildrenSynchronous(@NonNull String parentId) {
+ List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();
+ if (parentId.equals(getResources().getString(R.string.app_name))) {
+ long currentlyPlaying = PlaybackPreferences.getCurrentPlayerStatus();
+ if (currentlyPlaying == PlaybackPreferences.PLAYER_STATUS_PLAYING
+ || currentlyPlaying == PlaybackPreferences.PLAYER_STATUS_PAUSED) {
+ mediaItems.add(createBrowsableMediaItem(R.string.current_playing_episode, R.drawable.ic_play_48dp, 1));
+ }
+ mediaItems.add(createBrowsableMediaItem(R.string.queue_label, R.drawable.ic_playlist_play_black,
+ DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.QUEUED))));
+ mediaItems.add(createBrowsableMediaItem(R.string.downloads_label, R.drawable.ic_download_black,
+ DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.DOWNLOADED))));
+ mediaItems.add(createBrowsableMediaItem(R.string.episodes_label, R.drawable.ic_feed_black,
+ DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.UNPLAYED))));
+ List<Feed> feeds = DBReader.getFeedList();
+ for (Feed feed : feeds) {
+ mediaItems.add(createBrowsableMediaItemForFeed(feed));
+ }
+ return mediaItems;
+ }
+
+ List<FeedItem> feedItems;
+ if (parentId.equals(getResources().getString(R.string.queue_label))) {
+ feedItems = DBReader.getQueue();
+ } else if (parentId.equals(getResources().getString(R.string.downloads_label))) {
+ feedItems = DBReader.getEpisodes(0, MAX_ANDROID_AUTO_EPISODES_PER_FEED,
+ new FeedItemFilter(FeedItemFilter.DOWNLOADED), UserPreferences.getDownloadsSortedOrder());
+ } else if (parentId.equals(getResources().getString(R.string.episodes_label))) {
+ feedItems = DBReader.getEpisodes(0, MAX_ANDROID_AUTO_EPISODES_PER_FEED,
+ new FeedItemFilter(FeedItemFilter.UNPLAYED), UserPreferences.getAllEpisodesSortOrder());
+ } else if (parentId.startsWith("FeedId:")) {
+ long feedId = Long.parseLong(parentId.split(":")[1]);
+ Feed feed = DBReader.getFeed(feedId);
+ feedItems = DBReader.getFeedItemList(feed, FeedItemFilter.unfiltered(), feed.getSortOrder());
+ } else if (parentId.equals(getString(R.string.current_playing_episode))) {
+ FeedMedia playable = DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId());
+ if (playable != null) {
+ feedItems = Collections.singletonList(playable.getItem());
+ } else {
+ return null;
+ }
+ } else {
+ Log.e(TAG, "Parent ID not found: " + parentId);
+ return null;
+ }
+ int count = 0;
+ for (FeedItem feedItem : feedItems) {
+ if (feedItem.getMedia() != null && feedItem.getMedia().getMediaItem() != null) {
+ mediaItems.add(feedItem.getMedia().getMediaItem());
+ if (++count >= MAX_ANDROID_AUTO_EPISODES_PER_FEED) {
+ break;
+ }
+ }
+ }
+ return mediaItems;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ Log.d(TAG, "Received onBind event");
+ if (intent.getAction() != null && TextUtils.equals(intent.getAction(), MediaBrowserServiceCompat.SERVICE_INTERFACE)) {
+ return super.onBind(intent);
+ } else {
+ return mBinder;
+ }
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ super.onStartCommand(intent, flags, startId);
+ Log.d(TAG, "OnStartCommand called");
+
+ stateManager.startForeground(R.id.notification_playing, notificationBuilder.build());
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ notificationManager.cancel(R.id.notification_streaming_confirmation);
+
+ final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1);
+ final String customAction = intent.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION);
+ final boolean hardwareButton = intent.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false);
+ Playable playable = intent.getParcelableExtra(PlaybackServiceInterface.EXTRA_PLAYABLE);
+ if (keycode == -1 && playable == null && customAction == null) {
+ Log.e(TAG, "PlaybackService was started with no arguments");
+ stateManager.stopService();
+ return Service.START_NOT_STICKY;
+ }
+
+ if ((flags & Service.START_FLAG_REDELIVERY) != 0) {
+ Log.d(TAG, "onStartCommand is a redelivered intent, calling stopForeground now.");
+ stateManager.stopForeground(true);
+ } else {
+ if (keycode != -1) {
+ boolean notificationButton;
+ if (hardwareButton) {
+ Log.d(TAG, "Received hardware button event");
+ notificationButton = false;
+ } else {
+ Log.d(TAG, "Received media button event");
+ notificationButton = true;
+ }
+ boolean handled = handleKeycode(keycode, notificationButton);
+ if (!handled && !stateManager.hasReceivedValidStartCommand()) {
+ stateManager.stopService();
+ return Service.START_NOT_STICKY;
+ }
+ } else if (playable != null) {
+ stateManager.validStartCommandWasReceived();
+ boolean allowStreamThisTime = intent.getBooleanExtra(
+ PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME, false);
+ boolean allowStreamAlways = intent.getBooleanExtra(
+ PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS, false);
+ sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, 0);
+ if (allowStreamAlways) {
+ UserPreferences.setAllowMobileStreaming(true);
+ }
+ Observable.fromCallable(
+ () -> {
+ if (playable instanceof FeedMedia) {
+ return DBReader.getFeedMedia(((FeedMedia) playable).getId());
+ } else {
+ return playable;
+ }
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ loadedPlayable -> startPlaying(loadedPlayable, allowStreamThisTime),
+ error -> {
+ Log.d(TAG, "Playable was not found. Stopping service.");
+ error.printStackTrace();
+ stateManager.stopService();
+ });
+ return Service.START_NOT_STICKY;
+ } else {
+ mediaSession.getController().getTransportControls().sendCustomAction(customAction, null);
+ }
+ }
+
+ return Service.START_NOT_STICKY;
+ }
+
+ private void skipIntro(Playable playable) {
+ if (! (playable instanceof FeedMedia)) {
+ return;
+ }
+
+ FeedMedia feedMedia = (FeedMedia) playable;
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ int skipIntro = preferences.getFeedSkipIntro();
+
+ Context context = getApplicationContext();
+ if (skipIntro > 0 && playable.getPosition() < skipIntro * 1000) {
+ int duration = getDuration();
+ if (skipIntro * 1000 < duration || duration <= 0) {
+ Log.d(TAG, "skipIntro " + playable.getEpisodeTitle());
+ mediaPlayer.seekTo(skipIntro * 1000);
+ String skipIntroMesg = context.getString(R.string.pref_feed_skip_intro_toast,
+ skipIntro);
+ Toast toast = Toast.makeText(context, skipIntroMesg,
+ Toast.LENGTH_LONG);
+ toast.show();
+ }
+ }
+ }
+
+ @SuppressLint("LaunchActivityFromNotification")
+ private void displayStreamingNotAllowedNotification(Intent originalIntent) {
+ if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) {
+ EventBus.getDefault().post(new MessageEvent(
+ getString(R.string.confirm_mobile_streaming_notification_message)));
+ return;
+ }
+
+ Intent intentAllowThisTime = new Intent(originalIntent);
+ intentAllowThisTime.setAction(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME);
+ intentAllowThisTime.putExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME, true);
+ PendingIntent pendingIntentAllowThisTime;
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ pendingIntentAllowThisTime = PendingIntent.getForegroundService(this,
+ R.id.pending_intent_allow_stream_this_time, intentAllowThisTime,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ pendingIntentAllowThisTime = PendingIntent.getService(this,
+ R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ Intent intentAlwaysAllow = new Intent(intentAllowThisTime);
+ intentAlwaysAllow.setAction(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS);
+ intentAlwaysAllow.putExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_ALWAYS, true);
+ PendingIntent pendingIntentAlwaysAllow;
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ pendingIntentAlwaysAllow = PendingIntent.getForegroundService(this,
+ R.id.pending_intent_allow_stream_always, intentAlwaysAllow,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ pendingIntentAlwaysAllow = PendingIntent.getService(this,
+ R.id.pending_intent_allow_stream_always, intentAlwaysAllow, PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this,
+ NotificationUtils.CHANNEL_ID_USER_ACTION)
+ .setSmallIcon(R.drawable.ic_notification_stream)
+ .setContentTitle(getString(R.string.confirm_mobile_streaming_notification_title))
+ .setContentText(getString(R.string.confirm_mobile_streaming_notification_message))
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(getString(R.string.confirm_mobile_streaming_notification_message)))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntentAllowThisTime)
+ .addAction(R.drawable.ic_notification_stream,
+ getString(R.string.confirm_mobile_streaming_button_once),
+ pendingIntentAllowThisTime)
+ .addAction(R.drawable.ic_notification_stream,
+ getString(R.string.confirm_mobile_streaming_button_always),
+ pendingIntentAlwaysAllow)
+ .setAutoCancel(true);
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_streaming_confirmation, builder.build());
+ }
+ }
+
+ /**
+ * Handles media button events
+ * return: keycode was handled
+ */
+ private boolean handleKeycode(int keycode, boolean notificationButton) {
+ Log.d(TAG, "Handling keycode: " + keycode);
+ final PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo();
+ final PlayerStatus status = info.playerStatus;
+ switch (keycode) {
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ if (status == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(!UserPreferences.isPersistNotify(), false);
+ } else if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ mediaPlayer.resume();
+ } else if (status == PlayerStatus.PREPARING) {
+ mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared());
+ } else if (status == PlayerStatus.INITIALIZED) {
+ mediaPlayer.setStartWhenPrepared(true);
+ mediaPlayer.prepare();
+ } else if (mediaPlayer.getPlayable() == null) {
+ startPlayingFromPreferences();
+ } else {
+ return false;
+ }
+ taskManager.restartSleepTimer();
+ return true;
+ case KeyEvent.KEYCODE_MEDIA_PLAY:
+ if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ mediaPlayer.resume();
+ } else if (status == PlayerStatus.INITIALIZED) {
+ mediaPlayer.setStartWhenPrepared(true);
+ mediaPlayer.prepare();
+ } else if (mediaPlayer.getPlayable() == null) {
+ startPlayingFromPreferences();
+ } else {
+ return false;
+ }
+ taskManager.restartSleepTimer();
+ return true;
+ case KeyEvent.KEYCODE_MEDIA_PAUSE:
+ if (status == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(!UserPreferences.isPersistNotify(), false);
+ return true;
+ }
+ return false;
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ if (!notificationButton) {
+ // Handle remapped button as notification button which is not remapped again.
+ return handleKeycode(UserPreferences.getHardwareForwardButton(), true);
+ } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
+ mediaPlayer.skip();
+ return true;
+ }
+ return false;
+ case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
+ mediaPlayer.seekDelta(UserPreferences.getFastForwardSecs() * 1000);
+ return true;
+ }
+ return false;
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ if (!notificationButton) {
+ // Handle remapped button as notification button which is not remapped again.
+ return handleKeycode(UserPreferences.getHardwarePreviousButton(), true);
+ } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
+ mediaPlayer.seekTo(0);
+ return true;
+ }
+ return false;
+ case KeyEvent.KEYCODE_MEDIA_REWIND:
+ if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
+ mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000);
+ return true;
+ }
+ return false;
+ case KeyEvent.KEYCODE_MEDIA_STOP:
+ if (status == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(true, true);
+ }
+
+ stateManager.stopForeground(true); // gets rid of persistent notification
+ return true;
+ default:
+ Log.d(TAG, "Unhandled key code: " + keycode);
+ if (info.playable != null && info.playerStatus == PlayerStatus.PLAYING) { // only notify the user about an unknown key event if it is actually doing something
+ String message = String.format(getResources().getString(R.string.unknown_media_key), keycode);
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+ }
+ }
+ return false;
+ }
+
+ private void startPlayingFromPreferences() {
+ Observable.fromCallable(() -> DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId()))
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ playable -> startPlaying(playable, false),
+ error -> {
+ Log.d(TAG, "Playable was not loaded from preferences. Stopping service.");
+ error.printStackTrace();
+ stateManager.stopService();
+ });
+ }
+
+ private void startPlaying(Playable playable, boolean allowStreamThisTime) {
+ boolean localFeed = URLUtil.isContentUrl(playable.getStreamUrl());
+ boolean stream = !playable.localFileAvailable() || localFeed;
+ if (stream && !localFeed && !NetworkUtils.isStreamingAllowed() && !allowStreamThisTime) {
+ displayStreamingNotAllowedNotification(
+ new PlaybackServiceStarter(this, playable)
+ .getIntent());
+ PlaybackPreferences.writeNoMediaPlaying();
+ stateManager.stopService();
+ return;
+ }
+
+ if (!playable.getIdentifier().equals(PlaybackPreferences.getCurrentlyPlayingFeedMediaId())) {
+ PlaybackPreferences.clearCurrentlyPlayingTemporaryPlaybackSettings();
+ }
+
+ mediaPlayer.playMediaObject(playable, stream, true, true);
+ stateManager.validStartCommandWasReceived();
+ stateManager.startForeground(R.id.notification_playing, notificationBuilder.build());
+ recreateMediaSessionIfNeeded();
+ updateNotificationAndMediaSession(playable);
+ addPlayableToQueue(playable);
+ }
+
+ /**
+ * Called by a mediaplayer Activity as soon as it has prepared its
+ * mediaplayer.
+ */
+ public void setVideoSurface(SurfaceHolder sh) {
+ Log.d(TAG, "Setting display");
+ mediaPlayer.setVideoSurface(sh);
+ }
+
+ public void notifyVideoSurfaceAbandoned() {
+ mediaPlayer.pause(true, false);
+ mediaPlayer.resetVideoSurface();
+ updateNotificationAndMediaSession(getPlayable());
+ stateManager.stopForeground(!UserPreferences.isPersistNotify());
+ }
+
+ private final PlaybackServiceTaskManager.PSTMCallback taskManagerCallback = new PlaybackServiceTaskManager.PSTMCallback() {
+ @Override
+ public void positionSaverTick() {
+ saveCurrentPosition(true, null, Playable.INVALID_TIME);
+ }
+
+ @Override
+ public WidgetUpdater.WidgetState requestWidgetState() {
+ return new WidgetUpdater.WidgetState(getPlayable(), getStatus(),
+ getCurrentPosition(), getDuration(), getCurrentPlaybackSpeed());
+ }
+
+ @Override
+ public void onChapterLoaded(Playable media) {
+ sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, 0);
+ updateMediaSession(mediaPlayer.getPlayerStatus());
+ }
+ };
+
+ private final PlaybackServiceMediaPlayer.PSMPCallback mediaPlayerCallback = new PlaybackServiceMediaPlayer.PSMPCallback() {
+ @Override
+ public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) {
+ if (mediaPlayer != null) {
+ currentMediaType = mediaPlayer.getCurrentMediaType();
+ } else {
+ currentMediaType = MediaType.UNKNOWN;
+ }
+
+ updateMediaSession(newInfo.playerStatus);
+ switch (newInfo.playerStatus) {
+ case INITIALIZED:
+ if (mediaPlayer.getPSMPInfo().playable != null) {
+ PlaybackPreferences.writeMediaPlaying(mediaPlayer.getPSMPInfo().playable);
+ }
+ updateNotificationAndMediaSession(newInfo.playable);
+ break;
+ case PREPARED:
+ if (mediaPlayer.getPSMPInfo().playable != null) {
+ PlaybackPreferences.writeMediaPlaying(mediaPlayer.getPSMPInfo().playable);
+ }
+ taskManager.startChapterLoader(newInfo.playable);
+ break;
+ case PAUSED:
+ updateNotificationAndMediaSession(newInfo.playable);
+ if (!isCasting) {
+ stateManager.stopForeground(!UserPreferences.isPersistNotify());
+ }
+ cancelPositionObserver();
+ break;
+ case STOPPED:
+ //writePlaybackPreferencesNoMediaPlaying();
+ //stopService();
+ break;
+ case PLAYING:
+ saveCurrentPosition(true, null, Playable.INVALID_TIME);
+ recreateMediaSessionIfNeeded();
+ updateNotificationAndMediaSession(newInfo.playable);
+ setupPositionObserver();
+ stateManager.validStartCommandWasReceived();
+ stateManager.startForeground(R.id.notification_playing, notificationBuilder.build());
+ // set sleep timer if auto-enabled
+ boolean autoEnableByTime = true;
+ int fromSetting = SleepTimerPreferences.autoEnableFrom();
+ int toSetting = SleepTimerPreferences.autoEnableTo();
+ if (fromSetting != toSetting) {
+ Calendar now = new GregorianCalendar();
+ now.setTimeInMillis(System.currentTimeMillis());
+ int currentHour = now.get(Calendar.HOUR_OF_DAY);
+ autoEnableByTime = SleepTimerPreferences.isInTimeRange(fromSetting, toSetting, currentHour);
+ }
+
+ if (newInfo.oldPlayerStatus != null && newInfo.oldPlayerStatus != PlayerStatus.SEEKING
+ && SleepTimerPreferences.autoEnable() && autoEnableByTime && !sleepTimerActive()) {
+ setSleepTimer(SleepTimerPreferences.timerMillis());
+ EventBus.getDefault().post(new MessageEvent(getString(R.string.sleep_timer_enabled_label),
+ (ctx) -> disableSleepTimer(), getString(R.string.undo)));
+ }
+ loadQueueForMediaSession();
+ break;
+ case ERROR:
+ PlaybackPreferences.writeNoMediaPlaying();
+ stateManager.stopService();
+ break;
+ default:
+ break;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ TileService.requestListeningState(getApplicationContext(),
+ new ComponentName(getApplicationContext(), QuickSettingsTileService.class));
+ }
+
+ IntentUtils.sendLocalBroadcast(getApplicationContext(), ACTION_PLAYER_STATUS_CHANGED);
+ bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED);
+ bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED);
+ taskManager.requestWidgetUpdate();
+ EventBus.getDefault().post(new PlayerStatusEvent());
+ }
+
+ @Override
+ public void shouldStop() {
+ stateManager.stopForeground(!UserPreferences.isPersistNotify());
+ }
+
+ @Override
+ public void onMediaChanged(boolean reloadUI) {
+ Log.d(TAG, "reloadUI callback reached");
+ if (reloadUI) {
+ sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD, 0);
+ }
+ updateNotificationAndMediaSession(getPlayable());
+ }
+
+ @Override
+ public void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped,
+ boolean playingNext) {
+ PlaybackService.this.onPostPlayback(media, ended, skipped, playingNext);
+ }
+
+ @Override
+ public void onPlaybackStart(@NonNull Playable playable, int position) {
+ taskManager.startWidgetUpdater();
+ if (position != Playable.INVALID_TIME) {
+ playable.setPosition(position);
+ } else {
+ skipIntro(playable);
+ }
+ playable.onPlaybackStart();
+ taskManager.startPositionSaver();
+ }
+
+ @Override
+ public void onPlaybackPause(Playable playable, int position) {
+ taskManager.cancelPositionSaver();
+ cancelPositionObserver();
+ saveCurrentPosition(position == Playable.INVALID_TIME || playable == null, playable, position);
+ taskManager.cancelWidgetUpdater();
+ if (playable != null) {
+ if (playable instanceof FeedMedia) {
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(getApplicationContext(),
+ (FeedMedia) playable, false);
+ }
+ playable.onPlaybackPause(getApplicationContext());
+ }
+ }
+
+ @Override
+ public Playable getNextInQueue(Playable currentMedia) {
+ return PlaybackService.this.getNextInQueue(currentMedia);
+ }
+
+ @Nullable
+ @Override
+ public Playable findMedia(@NonNull String url) {
+ FeedItem item = DBReader.getFeedItemByGuidOrEpisodeUrl(null, url);
+ return item != null ? item.getMedia() : null;
+ }
+
+ @Override
+ public void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) {
+ PlaybackService.this.onPlaybackEnded(mediaType, stopPlaying);
+ }
+
+ @Override
+ public void ensureMediaInfoLoaded(@NonNull Playable media) {
+ if (media instanceof FeedMedia && ((FeedMedia) media).getItem() == null) {
+ ((FeedMedia) media).setItem(DBReader.getFeedItem(((FeedMedia) media).getItemId()));
+ }
+ }
+ };
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void playerError(PlayerErrorEvent event) {
+ if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(true, false);
+ }
+ stateManager.stopService();
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void bufferUpdate(BufferUpdateEvent event) {
+ if (event.hasEnded()) {
+ Playable playable = getPlayable();
+ if (getPlayable() instanceof FeedMedia
+ && playable.getDuration() <= 0 && mediaPlayer.getDuration() > 0) {
+ // Playable is being streamed and does not have a duration specified in the feed
+ playable.setDuration(mediaPlayer.getDuration());
+ DBWriter.setFeedMedia((FeedMedia) playable);
+ updateNotificationAndMediaSession(playable);
+ }
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void sleepTimerUpdate(SleepTimerUpdatedEvent event) {
+ if (event.isOver()) {
+ updateMediaSession(mediaPlayer.getPlayerStatus());
+ mediaPlayer.pause(true, true);
+ mediaPlayer.setVolume(1.0f, 1.0f);
+ int newPosition = mediaPlayer.getPosition() - (int) SleepTimer.NOTIFICATION_THRESHOLD / 2;
+ newPosition = Math.max(newPosition, 0);
+ seekTo(newPosition);
+ } else if (event.getTimeLeft() < SleepTimer.NOTIFICATION_THRESHOLD) {
+ final float[] multiplicators = {0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f};
+ float multiplicator = multiplicators[Math.max(0, (int) event.getTimeLeft() / 1000)];
+ Log.d(TAG, "onSleepTimerAlmostExpired: " + multiplicator);
+ mediaPlayer.setVolume(multiplicator, multiplicator);
+ } else if (event.isCancelled()) {
+ updateMediaSession(mediaPlayer.getPlayerStatus());
+ mediaPlayer.setVolume(1.0f, 1.0f);
+ } else if (event.wasJustEnabled()) {
+ updateMediaSession(mediaPlayer.getPlayerStatus());
+ }
+ }
+
+ private Playable getNextInQueue(final Playable currentMedia) {
+ if (!(currentMedia instanceof FeedMedia)) {
+ Log.d(TAG, "getNextInQueue(), but playable not an instance of FeedMedia, so not proceeding");
+ PlaybackPreferences.writeNoMediaPlaying();
+ return null;
+ }
+ Log.d(TAG, "getNextInQueue()");
+ FeedMedia media = (FeedMedia) currentMedia;
+ if (media.getItem() == null) {
+ media.setItem(DBReader.getFeedItem(media.getItemId()));
+ }
+ FeedItem item = media.getItem();
+ if (item == null) {
+ Log.w(TAG, "getNextInQueue() with FeedMedia object whose FeedItem is null");
+ PlaybackPreferences.writeNoMediaPlaying();
+ return null;
+ }
+ FeedItem nextItem;
+ nextItem = DBReader.getNextInQueue(item);
+
+ if (nextItem == null || nextItem.getMedia() == null) {
+ PlaybackPreferences.writeNoMediaPlaying();
+ return null;
+ }
+
+ if (!UserPreferences.isFollowQueue()) {
+ Log.d(TAG, "getNextInQueue(), but follow queue is not enabled.");
+ PlaybackPreferences.writeMediaPlaying(nextItem.getMedia());
+ updateNotificationAndMediaSession(nextItem.getMedia());
+ return null;
+ }
+
+ if (!nextItem.getMedia().localFileAvailable() && !NetworkUtils.isStreamingAllowed()
+ && UserPreferences.isFollowQueue() && !nextItem.getFeed().isLocalFeed()) {
+ displayStreamingNotAllowedNotification(
+ new PlaybackServiceStarter(this, nextItem.getMedia())
+ .getIntent());
+ PlaybackPreferences.writeNoMediaPlaying();
+ stateManager.stopService();
+ return null;
+ }
+ return nextItem.getMedia();
+ }
+
+ /**
+ * Set of instructions to be performed when playback ends.
+ */
+ private void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) {
+ Log.d(TAG, "Playback ended");
+ PlaybackPreferences.clearCurrentlyPlayingTemporaryPlaybackSettings();
+ if (stopPlaying) {
+ taskManager.cancelPositionSaver();
+ cancelPositionObserver();
+ if (!isCasting) {
+ stateManager.stopForeground(true);
+ stateManager.stopService();
+ }
+ }
+ if (mediaType == null) {
+ sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_PLAYBACK_END, 0);
+ } else {
+ sendNotificationBroadcast(PlaybackServiceInterface.NOTIFICATION_TYPE_RELOAD,
+ isCasting ? PlaybackServiceInterface.EXTRA_CODE_CAST :
+ (mediaType == MediaType.VIDEO) ? PlaybackServiceInterface.EXTRA_CODE_VIDEO :
+ PlaybackServiceInterface.EXTRA_CODE_AUDIO);
+ }
+ }
+
+ /**
+ * This method processes the media object after its playback ended, either because it completed
+ * or because a different media object was selected for playback.
+ * <p>
+ * Even though these tasks aren't supposed to be resource intensive, a good practice is to
+ * usually call this method on a background thread.
+ *
+ * @param playable the media object that was playing. It is assumed that its position
+ * property was updated before this method was called.
+ * @param ended if true, it signals that {@param playable} was played until its end.
+ * In such case, the position property of the media becomes irrelevant for
+ * most of the tasks (although it's still a good practice to keep it
+ * accurate).
+ * @param skipped if the user pressed a skip >| button.
+ * @param playingNext if true, it means another media object is being loaded in place of this
+ * one.
+ * Instances when we'd set it to false would be when we're not following the
+ * queue or when the queue has ended.
+ */
+ private void onPostPlayback(final Playable playable, boolean ended, boolean skipped,
+ boolean playingNext) {
+ if (playable == null) {
+ Log.e(TAG, "Cannot do post-playback processing: media was null");
+ return;
+ }
+ Log.d(TAG, "onPostPlayback(): media=" + playable.getEpisodeTitle());
+
+ if (!(playable instanceof FeedMedia)) {
+ Log.d(TAG, "Not doing post-playback processing: media not of type FeedMedia");
+ if (ended) {
+ playable.onPlaybackCompleted(getApplicationContext());
+ } else {
+ playable.onPlaybackPause(getApplicationContext());
+ }
+ return;
+ }
+ FeedMedia media = (FeedMedia) playable;
+ FeedItem item = media.getItem();
+ int smartMarkAsPlayedSecs = UserPreferences.getSmartMarkAsPlayedSecs();
+ boolean almostEnded = media.getDuration() > 0
+ && media.getPosition() >= media.getDuration() - smartMarkAsPlayedSecs * 1000;
+ if (!ended && almostEnded) {
+ Log.d(TAG, "smart mark as played");
+ }
+
+ boolean autoSkipped = false;
+ if (autoSkippedFeedMediaId != null && autoSkippedFeedMediaId.equals(item.getIdentifyingValue())) {
+ autoSkippedFeedMediaId = null;
+ autoSkipped = true;
+ }
+
+ if (ended || almostEnded) {
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
+ getApplicationContext(), media, true);
+ media.onPlaybackCompleted(getApplicationContext());
+ } else {
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
+ getApplicationContext(), media, false);
+ media.onPlaybackPause(getApplicationContext());
+ }
+
+ if (item != null) {
+ if (ended || almostEnded
+ || autoSkipped
+ || (skipped && !UserPreferences.shouldSkipKeepEpisode())) {
+ // only mark the item as played if we're not keeping it anyways
+ DBWriter.markItemPlayed(item, FeedItem.PLAYED, ended || (skipped && almostEnded));
+ // don't know if it actually matters to not autodownload when smart mark as played is triggered
+ DBWriter.removeQueueItem(PlaybackService.this, ended, item);
+ // Delete episode if enabled
+ FeedPreferences.AutoDeleteAction action =
+ item.getFeed().getPreferences().getCurrentAutoDelete();
+ boolean shouldAutoDelete = action == FeedPreferences.AutoDeleteAction.ALWAYS
+ || (action == FeedPreferences.AutoDeleteAction.GLOBAL
+ && FeedUtil.shouldAutoDeleteItemsOnThatFeed(item.getFeed()));
+ if (shouldAutoDelete && (!item.isTagged(FeedItem.TAG_FAVORITE)
+ || !UserPreferences.shouldFavoriteKeepEpisode())) {
+ DBWriter.deleteFeedMediaOfItem(PlaybackService.this, media);
+ Log.d(TAG, "Episode Deleted");
+ }
+ notifyChildrenChanged(getString(R.string.queue_label));
+ }
+ }
+
+ if (ended || skipped || playingNext) {
+ DBWriter.addItemToPlaybackHistory(media);
+ }
+ }
+
+ public void setSleepTimer(long waitingTime) {
+ Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds");
+ taskManager.setSleepTimer(waitingTime);
+ }
+
+ public void disableSleepTimer() {
+ taskManager.disableSleepTimer();
+ }
+
+ private void sendNotificationBroadcast(int type, int code) {
+ Intent intent = new Intent(PlaybackServiceInterface.ACTION_PLAYER_NOTIFICATION);
+ intent.putExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_TYPE, type);
+ intent.putExtra(PlaybackServiceInterface.EXTRA_NOTIFICATION_CODE, code);
+ intent.setPackage(getPackageName());
+ sendBroadcast(intent);
+ }
+
+ private void skipEndingIfNecessary() {
+ Playable playable = mediaPlayer.getPlayable();
+ if (! (playable instanceof FeedMedia)) {
+ return;
+ }
+
+ int duration = getDuration();
+ int remainingTime = duration - getCurrentPosition();
+
+ FeedMedia feedMedia = (FeedMedia) playable;
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ int skipEnd = preferences.getFeedSkipEnding();
+ if (skipEnd > 0
+ && skipEnd * 1000 < getDuration()
+ && (remainingTime - (skipEnd * 1000) > 0)
+ && ((remainingTime - skipEnd * 1000) < (getCurrentPlaybackSpeed() * 1000))) {
+ Log.d(TAG, "skipEndingIfNecessary: Skipping the remaining " + remainingTime + " " + skipEnd * 1000 + " speed " + getCurrentPlaybackSpeed());
+ Context context = getApplicationContext();
+ String skipMesg = context.getString(R.string.pref_feed_skip_ending_toast, skipEnd);
+ Toast toast = Toast.makeText(context, skipMesg, Toast.LENGTH_LONG);
+ toast.show();
+
+ this.autoSkippedFeedMediaId = feedMedia.getItem().getIdentifyingValue();
+ mediaPlayer.skip();
+ }
+ }
+
+ /**
+ * Updates the Media Session for the corresponding status.
+ *
+ * @param playerStatus the current {@link PlayerStatus}
+ */
+ private void updateMediaSession(final PlayerStatus playerStatus) {
+ PlaybackStateCompat.Builder sessionState = new PlaybackStateCompat.Builder();
+
+ int state;
+ if (playerStatus != null) {
+ switch (playerStatus) {
+ case PLAYING:
+ state = PlaybackStateCompat.STATE_PLAYING;
+ break;
+ case PREPARED:
+ case PAUSED:
+ state = PlaybackStateCompat.STATE_PAUSED;
+ break;
+ case STOPPED:
+ state = PlaybackStateCompat.STATE_STOPPED;
+ break;
+ case SEEKING:
+ state = PlaybackStateCompat.STATE_FAST_FORWARDING;
+ break;
+ case PREPARING:
+ case INITIALIZING:
+ state = PlaybackStateCompat.STATE_CONNECTING;
+ break;
+ case ERROR:
+ state = PlaybackStateCompat.STATE_ERROR;
+ break;
+ case INITIALIZED: // Deliberate fall-through
+ case INDETERMINATE:
+ default:
+ state = PlaybackStateCompat.STATE_NONE;
+ break;
+ }
+ } else {
+ state = PlaybackStateCompat.STATE_NONE;
+ }
+
+ sessionState.setState(state, getCurrentPosition(), getCurrentPlaybackSpeed());
+ long capabilities = PlaybackStateCompat.ACTION_PLAY
+ | PlaybackStateCompat.ACTION_PLAY_PAUSE
+ | PlaybackStateCompat.ACTION_REWIND
+ | PlaybackStateCompat.ACTION_PAUSE
+ | PlaybackStateCompat.ACTION_FAST_FORWARD
+ | PlaybackStateCompat.ACTION_SEEK_TO
+ | PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED;
+
+ sessionState.setActions(capabilities);
+
+ // On Android Auto, custom actions are added in the following order around the play button, if no default
+ // actions are present: Near left, near right, far left, far right, additional actions panel
+ PlaybackStateCompat.CustomAction.Builder rewindBuilder = new PlaybackStateCompat.CustomAction.Builder(
+ CUSTOM_ACTION_REWIND,
+ getString(R.string.rewind_label),
+ R.drawable.ic_notification_fast_rewind
+ );
+ WearMediaSession.addWearExtrasToAction(rewindBuilder);
+ sessionState.addCustomAction(rewindBuilder.build());
+
+ PlaybackStateCompat.CustomAction.Builder fastForwardBuilder = new PlaybackStateCompat.CustomAction.Builder(
+ CUSTOM_ACTION_FAST_FORWARD,
+ getString(R.string.fast_forward_label),
+ R.drawable.ic_notification_fast_forward
+ );
+ WearMediaSession.addWearExtrasToAction(fastForwardBuilder);
+ sessionState.addCustomAction(fastForwardBuilder.build());
+
+ if (UserPreferences.showPlaybackSpeedOnFullNotification()) {
+ sessionState.addCustomAction(
+ new PlaybackStateCompat.CustomAction.Builder(
+ CUSTOM_ACTION_CHANGE_PLAYBACK_SPEED,
+ getString(R.string.playback_speed),
+ R.drawable.ic_notification_playback_speed
+ ).build()
+ );
+ }
+
+ if (UserPreferences.showSleepTimerOnFullNotification()) {
+ @DrawableRes int icon = R.drawable.ic_notification_sleep;
+ if (sleepTimerActive()) {
+ icon = R.drawable.ic_notification_sleep_off;
+ }
+ sessionState.addCustomAction(
+ new PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_TOGGLE_SLEEP_TIMER,
+ getString(R.string.sleep_timer_label), icon).build());
+ }
+
+ if (UserPreferences.showNextChapterOnFullNotification()) {
+ if (getPlayable() != null && getPlayable().getChapters() != null) {
+ sessionState.addCustomAction(
+ new PlaybackStateCompat.CustomAction.Builder(
+ CUSTOM_ACTION_NEXT_CHAPTER,
+ getString(R.string.next_chapter), R.drawable.ic_notification_next_chapter)
+ .build());
+ }
+ }
+
+ if (UserPreferences.showSkipOnFullNotification()) {
+ sessionState.addCustomAction(
+ new PlaybackStateCompat.CustomAction.Builder(
+ CUSTOM_ACTION_SKIP_TO_NEXT,
+ getString(R.string.skip_episode_label),
+ R.drawable.ic_notification_skip
+ ).build()
+ );
+ }
+
+ WearMediaSession.mediaSessionSetExtraForWear(mediaSession);
+
+ mediaSession.setPlaybackState(sessionState.build());
+ }
+
+ private void updateNotificationAndMediaSession(final Playable p) {
+ setupNotification(p);
+ updateMediaSessionMetadata(p);
+ }
+
+ 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.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle());
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, p.getDuration());
+ builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle());
+ builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, p.getFeedTitle());
+
+
+ if (notificationBuilder.isIconCached()) {
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, notificationBuilder.getCachedIcon());
+ } else {
+ String iconUri = p.getImageLocation();
+ if (p instanceof FeedMedia) { // Don't use embedded cover etc, which Android can't load
+ FeedMedia m = (FeedMedia) p;
+ if (m.getItem() != null) {
+ FeedItem item = m.getItem();
+ if (item.getImageUrl() != null) {
+ iconUri = item.getImageUrl();
+ } else if (item.getFeed() != null) {
+ iconUri = item.getFeed().getImageUrl();
+ }
+ }
+ }
+ if (!TextUtils.isEmpty(iconUri)) {
+ builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri);
+ }
+ }
+
+ if (stateManager.hasReceivedValidStartCommand()) {
+ mediaSession.setSessionActivity(PendingIntent.getActivity(this, R.id.pending_intent_player_activity,
+ PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0)));
+ try {
+ mediaSession.setMetadata(builder.build());
+ } catch (OutOfMemoryError e) {
+ Log.e(TAG, "Setting media session metadata", e);
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, null);
+ mediaSession.setMetadata(builder.build());
+ }
+ }
+ }
+
+ /**
+ * Used by setupNotification to load notification data in another thread.
+ */
+ private Thread playableIconLoaderThread;
+
+ /**
+ * Prepares notification and starts the service in the foreground.
+ */
+ private synchronized void setupNotification(final Playable playable) {
+ Log.d(TAG, "setupNotification");
+ if (playableIconLoaderThread != null) {
+ playableIconLoaderThread.interrupt();
+ }
+ if (playable == null || mediaPlayer == null) {
+ Log.d(TAG, "setupNotification: playable=" + playable);
+ Log.d(TAG, "setupNotification: mediaPlayer=" + mediaPlayer);
+ if (!stateManager.hasReceivedValidStartCommand()) {
+ stateManager.stopService();
+ }
+ return;
+ }
+
+ PlayerStatus playerStatus = mediaPlayer.getPlayerStatus();
+ notificationBuilder.setPlayable(playable);
+ notificationBuilder.setMediaSessionToken(mediaSession.getSessionToken());
+ notificationBuilder.setPlayerStatus(playerStatus);
+ notificationBuilder.updatePosition(getCurrentPosition(), getCurrentPlaybackSpeed());
+
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_playing, notificationBuilder.build());
+ }
+
+ if (!notificationBuilder.isIconCached()) {
+ playableIconLoaderThread = new Thread(() -> {
+ Log.d(TAG, "Loading notification icon");
+ notificationBuilder.loadIcon();
+ if (!Thread.currentThread().isInterrupted()) {
+ if (ContextCompat.checkSelfPermission(getApplicationContext(),
+ Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_playing, notificationBuilder.build());
+ }
+ updateMediaSessionMetadata(playable);
+ }
+ });
+ playableIconLoaderThread.start();
+ }
+ }
+
+ /**
+ * Persists the current position and last played time of the media file.
+ *
+ * @param fromMediaPlayer if true, the information is gathered from the current Media Player
+ * and {@param playable} and {@param position} become irrelevant.
+ * @param playable the playable for which the current position should be saved, unless
+ * {@param fromMediaPlayer} is true.
+ * @param position the position that should be saved, unless {@param fromMediaPlayer} is true.
+ */
+ private synchronized void saveCurrentPosition(boolean fromMediaPlayer, Playable playable, int position) {
+ int duration;
+ if (fromMediaPlayer) {
+ position = getCurrentPosition();
+ duration = getDuration();
+ playable = mediaPlayer.getPlayable();
+ } else {
+ duration = playable.getDuration();
+ }
+ if (position != Playable.INVALID_TIME && duration != Playable.INVALID_TIME && playable != null) {
+ Log.d(TAG, "Saving current position to " + position);
+ PlayableUtils.saveCurrentPosition(playable, position, System.currentTimeMillis());
+ }
+ }
+
+ public boolean sleepTimerActive() {
+ return taskManager.isSleepTimerActive();
+ }
+
+ public long getSleepTimerTimeLeft() {
+ return taskManager.getSleepTimerTimeLeft();
+ }
+
+ private void bluetoothNotifyChange(PlaybackServiceMediaPlayer.PSMPInfo info, String whatChanged) {
+ boolean isPlaying = false;
+
+ if (info.playerStatus == PlayerStatus.PLAYING) {
+ isPlaying = true;
+ }
+
+ if (info.playable != null) {
+ Intent i = new Intent(whatChanged);
+ i.putExtra("id", 1L);
+ i.putExtra("artist", "");
+ i.putExtra("album", info.playable.getFeedTitle());
+ i.putExtra("track", info.playable.getEpisodeTitle());
+ i.putExtra("playing", isPlaying);
+ i.putExtra("duration", (long) info.playable.getDuration());
+ i.putExtra("position", (long) info.playable.getPosition());
+ sendBroadcast(i);
+ }
+ }
+
+ private final BroadcastReceiver autoStateUpdated = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String status = intent.getStringExtra("media_connection_status");
+ boolean isConnectedToCar = "media_connected".equals(status);
+ Log.d(TAG, "Received Auto Connection update: " + status);
+ if (!isConnectedToCar) {
+ Log.d(TAG, "Car was unplugged during playback.");
+ } else {
+ PlayerStatus playerStatus = mediaPlayer.getPlayerStatus();
+ if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) {
+ mediaPlayer.resume();
+ } else if (playerStatus == PlayerStatus.PREPARING) {
+ mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared());
+ } else if (playerStatus == PlayerStatus.INITIALIZED) {
+ mediaPlayer.setStartWhenPrepared(true);
+ mediaPlayer.prepare();
+ }
+ }
+ }
+ };
+
+ /**
+ * Pauses playback when the headset is disconnected and the preference is
+ * set
+ */
+ private final BroadcastReceiver headsetDisconnected = new BroadcastReceiver() {
+ private static final String TAG = "headsetDisconnected";
+ private static final int UNPLUGGED = 0;
+ private static final int PLUGGED = 1;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (isInitialStickyBroadcast()) {
+ // Don't pause playback after we just started, just because the receiver
+ // delivers the current headset state (instead of a change)
+ return;
+ }
+
+ if (TextUtils.equals(intent.getAction(), Intent.ACTION_HEADSET_PLUG)) {
+ int state = intent.getIntExtra("state", -1);
+ Log.d(TAG, "Headset plug event. State is " + state);
+ if (state != -1) {
+ if (state == UNPLUGGED) {
+ Log.d(TAG, "Headset was unplugged during playback.");
+ } else if (state == PLUGGED) {
+ Log.d(TAG, "Headset was plugged in during playback.");
+ unpauseIfPauseOnDisconnect(false);
+ }
+ } else {
+ Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent");
+ }
+ }
+ }
+ };
+
+ private final BroadcastReceiver bluetoothStateUpdated = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (TextUtils.equals(intent.getAction(), BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)) {
+ int state = intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, -1);
+ if (state == BluetoothA2dp.STATE_CONNECTED) {
+ Log.d(TAG, "Received bluetooth connection intent");
+ unpauseIfPauseOnDisconnect(true);
+ }
+ }
+ }
+ };
+
+ private final BroadcastReceiver audioBecomingNoisy = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // sound is about to change, eg. bluetooth -> speaker
+ Log.d(TAG, "Pausing playback because audio is becoming noisy");
+ pauseIfPauseOnDisconnect();
+ }
+ };
+
+ /**
+ * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true.
+ */
+ private void pauseIfPauseOnDisconnect() {
+ Log.d(TAG, "pauseIfPauseOnDisconnect()");
+ transientPause = (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING);
+ if (UserPreferences.isPauseOnHeadsetDisconnect() && !isCasting()) {
+ mediaPlayer.pause(!UserPreferences.isPersistNotify(), false);
+ }
+ }
+
+ /**
+ * @param bluetooth true if the event for unpausing came from bluetooth
+ */
+ private void unpauseIfPauseOnDisconnect(boolean bluetooth) {
+ if (mediaPlayer.isAudioChannelInUse()) {
+ Log.d(TAG, "unpauseIfPauseOnDisconnect() audio is in use");
+ return;
+ }
+ if (transientPause) {
+ transientPause = false;
+ if (Build.VERSION.SDK_INT >= 31) {
+ stateManager.stopService();
+ return;
+ }
+ if (!bluetooth && UserPreferences.isUnpauseOnHeadsetReconnect()) {
+ mediaPlayer.resume();
+ } else if (bluetooth && UserPreferences.isUnpauseOnBluetoothReconnect()) {
+ // let the user know we've started playback again...
+ Vibrator v = (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE);
+ if (v != null) {
+ v.vibrate(500);
+ }
+ mediaPlayer.resume();
+ }
+ }
+ }
+
+ private final BroadcastReceiver shutdownReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (TextUtils.equals(intent.getAction(), PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE)) {
+ EventBus.getDefault().post(new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN));
+ stateManager.stopService();
+ }
+ }
+
+ };
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void volumeAdaptionChanged(VolumeAdaptionChangedEvent event) {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, event.getFeedId(), event.getVolumeAdaptionSetting());
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void speedPresetChanged(SpeedPresetChangedEvent event) {
+ if (getPlayable() instanceof FeedMedia) {
+ FeedMedia playable = (FeedMedia) getPlayable();
+ if (playable.getItem().getFeed().getId() == event.getFeedId()) {
+ if (event.getSpeed() == SPEED_USE_GLOBAL) {
+ setSpeed(UserPreferences.getPlaybackSpeed());
+ } else {
+ setSpeed(event.getSpeed());
+ }
+ if (event.getSkipSilence() == FeedPreferences.SkipSilence.GLOBAL) {
+ setSkipSilence(UserPreferences.isSkipSilence());
+ } else {
+ setSkipSilence(event.getSkipSilence() == FeedPreferences.SkipSilence.AGGRESSIVE);
+ }
+ }
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ @SuppressWarnings("unused")
+ public void skipIntroEndingPresetChanged(SkipIntroEndingChangedEvent event) {
+ if (getPlayable() instanceof FeedMedia) {
+ FeedMedia playable = (FeedMedia) getPlayable();
+ if (playable.getItem().getFeed().getId() == event.getFeedId()) {
+ if (event.getSkipEnding() != 0) {
+ FeedPreferences feedPreferences = playable.getItem().getFeed().getPreferences();
+ feedPreferences.setFeedSkipIntro(event.getSkipIntro());
+ feedPreferences.setFeedSkipEnding(event.getSkipEnding());
+ }
+ }
+ }
+ }
+
+ public static MediaType getCurrentMediaType() {
+ return currentMediaType;
+ }
+
+ public static boolean isCasting() {
+ return isCasting;
+ }
+
+ public void resume() {
+ mediaPlayer.resume();
+ taskManager.restartSleepTimer();
+ }
+
+ public void prepare() {
+ mediaPlayer.prepare();
+ taskManager.restartSleepTimer();
+ }
+
+ public void pause(boolean abandonAudioFocus, boolean reinit) {
+ mediaPlayer.pause(abandonAudioFocus, reinit);
+ }
+
+ public PlaybackServiceMediaPlayer.PSMPInfo getPSMPInfo() {
+ return mediaPlayer.getPSMPInfo();
+ }
+
+ public PlayerStatus getStatus() {
+ return mediaPlayer.getPlayerStatus();
+ }
+
+ public Playable getPlayable() {
+ return mediaPlayer.getPlayable();
+ }
+
+ public void setSpeed(float speed) {
+ PlaybackPreferences.setCurrentlyPlayingTemporaryPlaybackSpeed(speed);
+ mediaPlayer.setPlaybackParams(speed, getCurrentSkipSilence());
+ }
+
+ public void setSkipSilence(boolean skipSilence) {
+ PlaybackPreferences.setCurrentlyPlayingTemporarySkipSilence(skipSilence);
+ mediaPlayer.setPlaybackParams(getCurrentPlaybackSpeed(), skipSilence);
+ }
+
+ public float getCurrentPlaybackSpeed() {
+ if (mediaPlayer == null) {
+ return 1.0f;
+ }
+ return mediaPlayer.getPlaybackSpeed();
+ }
+
+ public boolean getCurrentSkipSilence() {
+ if (mediaPlayer == null) {
+ return false;
+ }
+ return mediaPlayer.getSkipSilence();
+ }
+
+ public boolean isStartWhenPrepared() {
+ return mediaPlayer.isStartWhenPrepared();
+ }
+
+ public void setStartWhenPrepared(boolean s) {
+ mediaPlayer.setStartWhenPrepared(s);
+ }
+
+ public void seekTo(final int t) {
+ mediaPlayer.seekTo(t);
+ EventBus.getDefault().post(new PlaybackPositionEvent(t, getDuration()));
+ }
+
+ private void seekDelta(final int d) {
+ mediaPlayer.seekDelta(d);
+ }
+
+ /**
+ * call getDuration() on mediaplayer or return INVALID_TIME if player is in
+ * an invalid state.
+ */
+ public int getDuration() {
+ if (mediaPlayer == null) {
+ return Playable.INVALID_TIME;
+ }
+ return mediaPlayer.getDuration();
+ }
+
+ /**
+ * call getCurrentPosition() on mediaplayer or return INVALID_TIME if player
+ * is in an invalid state.
+ */
+ public int getCurrentPosition() {
+ if (mediaPlayer == null) {
+ return Playable.INVALID_TIME;
+ }
+ return mediaPlayer.getPosition();
+ }
+
+ public List<String> getAudioTracks() {
+ if (mediaPlayer == null) {
+ return Collections.emptyList();
+ }
+ return mediaPlayer.getAudioTracks();
+ }
+
+ public int getSelectedAudioTrack() {
+ if (mediaPlayer == null) {
+ return -1;
+ }
+ return mediaPlayer.getSelectedAudioTrack();
+ }
+
+ public void setAudioTrack(int track) {
+ if (mediaPlayer != null) {
+ mediaPlayer.setAudioTrack(track);
+ }
+ }
+
+ public boolean isStreaming() {
+ return mediaPlayer.isStreaming();
+ }
+
+ public Pair<Integer, Integer> getVideoSize() {
+ return mediaPlayer.getVideoSize();
+ }
+
+ private void setupPositionObserver() {
+ if (positionEventTimer != null) {
+ positionEventTimer.dispose();
+ }
+
+ Log.d(TAG, "Setting up position observer");
+ positionEventTimer = Observable.interval(1, TimeUnit.SECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(number -> {
+ EventBus.getDefault().post(new PlaybackPositionEvent(getCurrentPosition(), getDuration()));
+ if (Build.VERSION.SDK_INT < 29) {
+ notificationBuilder.updatePosition(getCurrentPosition(), getCurrentPlaybackSpeed());
+ NotificationManager notificationManager = (NotificationManager)
+ getSystemService(NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(),
+ Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_playing, notificationBuilder.build());
+ }
+ }
+ skipEndingIfNecessary();
+ });
+ }
+
+ private void cancelPositionObserver() {
+ if (positionEventTimer != null) {
+ positionEventTimer.dispose();
+ }
+ }
+
+ private void addPlayableToQueue(Playable playable) {
+ if (playable instanceof FeedMedia) {
+ long itemId = ((FeedMedia) playable).getItem().getId();
+ DBWriter.addQueueItem(this, false, true, itemId);
+ notifyChildrenChanged(getString(R.string.queue_label));
+ }
+ }
+
+ private final MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback() {
+
+ private static final String TAG = "MediaSessionCompat";
+
+ @Override
+ public void onPlay() {
+ Log.d(TAG, "onPlay()");
+ PlayerStatus status = getStatus();
+ if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ resume();
+ } else if (status == PlayerStatus.INITIALIZED) {
+ setStartWhenPrepared(true);
+ prepare();
+ }
+ }
+
+ @Override
+ public void onPlayFromMediaId(String mediaId, Bundle extras) {
+ Log.d(TAG, "onPlayFromMediaId: mediaId: " + mediaId + " extras: " + extras.toString());
+ FeedMedia p = DBReader.getFeedMedia(Long.parseLong(mediaId));
+ if (p != null) {
+ startPlaying(p, false);
+ }
+ }
+
+ @Override
+ public void onPlayFromSearch(String query, Bundle extras) {
+ Log.d(TAG, "onPlayFromSearch query=" + query + " extras=" + extras.toString());
+
+ if (query.equals("")) {
+ Log.d(TAG, "onPlayFromSearch called with empty query, resuming from the last position");
+ startPlayingFromPreferences();
+ return;
+ }
+
+ List<FeedItem> results = DBReader.searchFeedItems(0, query);
+ if (results.size() > 0 && results.get(0).getMedia() != null) {
+ FeedMedia media = results.get(0).getMedia();
+ startPlaying(media, false);
+ return;
+ }
+ onPlay();
+ }
+
+ @Override
+ public void onPause() {
+ Log.d(TAG, "onPause()");
+ if (getStatus() == PlayerStatus.PLAYING) {
+ pause(!UserPreferences.isPersistNotify(), false);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ Log.d(TAG, "onStop()");
+ mediaPlayer.stopPlayback(true);
+ }
+
+ @Override
+ public void onSkipToPrevious() {
+ Log.d(TAG, "onSkipToPrevious()");
+ seekDelta(-UserPreferences.getRewindSecs() * 1000);
+ }
+
+ @Override
+ public void onRewind() {
+ Log.d(TAG, "onRewind()");
+ seekDelta(-UserPreferences.getRewindSecs() * 1000);
+ }
+
+ public void onNextChapter() {
+ List<Chapter> chapters = mediaPlayer.getPlayable().getChapters();
+ if (chapters == null) {
+ // No chapters, just fallback to next episode
+ mediaPlayer.skip();
+ return;
+ }
+
+ int nextChapter = ChapterUtils.getCurrentChapterIndex(
+ mediaPlayer.getPlayable(), mediaPlayer.getPosition()) + 1;
+
+ if (chapters.size() < nextChapter + 1) {
+ // We are on the last chapter, just fallback to the next episode
+ mediaPlayer.skip();
+ return;
+ }
+
+ mediaPlayer.seekTo((int) chapters.get(nextChapter).getStart());
+ }
+
+ @Override
+ public void onFastForward() {
+ Log.d(TAG, "onFastForward()");
+ seekDelta(UserPreferences.getFastForwardSecs() * 1000);
+ }
+
+ @Override
+ public void onSkipToNext() {
+ Log.d(TAG, "onSkipToNext()");
+ UiModeManager uiModeManager = (UiModeManager) getApplicationContext()
+ .getSystemService(Context.UI_MODE_SERVICE);
+ if (UserPreferences.getHardwareForwardButton() == KeyEvent.KEYCODE_MEDIA_NEXT
+ || uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) {
+ mediaPlayer.skip();
+ } else {
+ seekDelta(UserPreferences.getFastForwardSecs() * 1000);
+ }
+ }
+
+
+ @Override
+ public void onSeekTo(long pos) {
+ Log.d(TAG, "onSeekTo()");
+ seekTo((int) pos);
+ }
+
+ @Override
+ public void onSetPlaybackSpeed(float speed) {
+ Log.d(TAG, "onSetPlaybackSpeed()");
+ setSpeed(speed);
+ }
+
+ @Override
+ public boolean onMediaButtonEvent(final Intent mediaButton) {
+ Log.d(TAG, "onMediaButtonEvent(" + mediaButton + ")");
+ if (mediaButton != null) {
+ KeyEvent keyEvent = mediaButton.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
+ if (keyEvent != null &&
+ keyEvent.getAction() == KeyEvent.ACTION_DOWN &&
+ keyEvent.getRepeatCount() == 0) {
+ int keyCode = keyEvent.getKeyCode();
+ if (keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) {
+ clickCount++;
+ clickHandler.removeCallbacksAndMessages(null);
+ clickHandler.postDelayed(() -> {
+ if (clickCount == 1) {
+ handleKeycode(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false);
+ } else if (clickCount == 2) {
+ onFastForward();
+ } else if (clickCount == 3) {
+ onRewind();
+ }
+ clickCount = 0;
+ }, ViewConfiguration.getDoubleTapTimeout());
+ return true;
+ } else {
+ return handleKeycode(keyCode, false);
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void onCustomAction(String action, Bundle extra) {
+ Log.d(TAG, "onCustomAction(" + action + ")");
+ if (CUSTOM_ACTION_FAST_FORWARD.equals(action)) {
+ onFastForward();
+ } else if (CUSTOM_ACTION_REWIND.equals(action)) {
+ onRewind();
+ } else if (CUSTOM_ACTION_SKIP_TO_NEXT.equals(action)) {
+ mediaPlayer.skip();
+ } else if (CUSTOM_ACTION_NEXT_CHAPTER.equals(action)) {
+ onNextChapter();
+ } else if (CUSTOM_ACTION_CHANGE_PLAYBACK_SPEED.equals(action)) {
+ List<Float> selectedSpeeds = UserPreferences.getPlaybackSpeedArray();
+
+ // If the list has zero or one element, there's nothing we can do to change the playback speed.
+ if (selectedSpeeds.size() > 1) {
+ int speedPosition = selectedSpeeds.indexOf(mediaPlayer.getPlaybackSpeed());
+ float newSpeed;
+
+ if (speedPosition == selectedSpeeds.size() - 1) {
+ // This is the last element. Wrap instead of going over the size of the list.
+ newSpeed = selectedSpeeds.get(0);
+ } else {
+ // If speedPosition is still -1 (the user isn't using a preset), use the first preset in the
+ // list.
+ newSpeed = selectedSpeeds.get(speedPosition + 1);
+ }
+ onSetPlaybackSpeed(newSpeed);
+ }
+ } else if (CUSTOM_ACTION_TOGGLE_SLEEP_TIMER.equals(action)) {
+ if (sleepTimerActive()) {
+ disableSleepTimer();
+ } else {
+ setSleepTimer(SleepTimerPreferences.timerMillis());
+ }
+ }
+ }
+ };
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceInterface.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceInterface.java
new file mode 100644
index 000000000..93a0559c7
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceInterface.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.playback.service;
+
+public abstract class PlaybackServiceInterface {
+ public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra";
+ public static final String EXTRA_ALLOW_STREAM_THIS_TIME = "extra.de.danoeh.antennapod.core.service.allowStream";
+ public static final String EXTRA_ALLOW_STREAM_ALWAYS = "extra.de.danoeh.antennapod.core.service.allowStreamAlways";
+
+ public static final String ACTION_PLAYER_NOTIFICATION
+ = "action.de.danoeh.antennapod.core.service.playerNotification";
+ public static final String EXTRA_NOTIFICATION_CODE = "extra.de.danoeh.antennapod.core.service.notificationCode";
+ public static final String EXTRA_NOTIFICATION_TYPE = "extra.de.danoeh.antennapod.core.service.notificationType";
+ public static final int NOTIFICATION_TYPE_PLAYBACK_END = 7;
+ public static final int NOTIFICATION_TYPE_RELOAD = 3;
+ public static final int EXTRA_CODE_AUDIO = 1; // Used in NOTIFICATION_TYPE_RELOAD
+ public static final int EXTRA_CODE_VIDEO = 2;
+ public static final int EXTRA_CODE_CAST = 3;
+
+ public static final String ACTION_SHUTDOWN_PLAYBACK_SERVICE
+ = "action.de.danoeh.antennapod.core.service.actionShutdownPlaybackService";
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceStarter.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceStarter.java
new file mode 100644
index 000000000..b96ef12f8
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackServiceStarter.java
@@ -0,0 +1,47 @@
+package de.danoeh.antennapod.playback.service;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Parcelable;
+import androidx.core.content.ContextCompat;
+
+import de.danoeh.antennapod.model.playback.Playable;
+
+public class PlaybackServiceStarter {
+ private final Context context;
+ private final Playable media;
+ private boolean shouldStreamThisTime = false;
+ private boolean callEvenIfRunning = false;
+
+ public PlaybackServiceStarter(Context context, Playable media) {
+ this.context = context;
+ this.media = media;
+ }
+
+ /**
+ * Default value: false
+ */
+ public PlaybackServiceStarter callEvenIfRunning(boolean callEvenIfRunning) {
+ this.callEvenIfRunning = callEvenIfRunning;
+ return this;
+ }
+
+ public PlaybackServiceStarter shouldStreamThisTime(boolean shouldStreamThisTime) {
+ this.shouldStreamThisTime = shouldStreamThisTime;
+ return this;
+ }
+
+ public Intent getIntent() {
+ Intent launchIntent = new Intent(context, PlaybackService.class);
+ launchIntent.putExtra(PlaybackServiceInterface.EXTRA_PLAYABLE, (Parcelable) media);
+ launchIntent.putExtra(PlaybackServiceInterface.EXTRA_ALLOW_STREAM_THIS_TIME, shouldStreamThisTime);
+ return launchIntent;
+ }
+
+ public void start() {
+ if (PlaybackService.isRunning && !callEvenIfRunning) {
+ return;
+ }
+ ContextCompat.startForegroundService(context, getIntent());
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackStatus.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackStatus.java
new file mode 100644
index 000000000..043cf7198
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/PlaybackStatus.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.playback.service;
+
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+
+public abstract class PlaybackStatus {
+ /**
+ * Reads playback preferences to determine whether this FeedMedia object is
+ * currently being played and the current player status is playing.
+ */
+ public static boolean isCurrentlyPlaying(FeedMedia media) {
+ return isPlaying(media) && PlaybackService.isRunning
+ && ((PlaybackPreferences.getCurrentPlayerStatus() == PlaybackPreferences.PLAYER_STATUS_PLAYING));
+ }
+
+ public static boolean isPlaying(FeedMedia media) {
+ return PlaybackPreferences.getCurrentlyPlayingMediaType() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA
+ && media != null
+ && PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == media.getId();
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/QuickSettingsTileService.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/QuickSettingsTileService.java
new file mode 100644
index 000000000..febfe1a4e
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/QuickSettingsTileService.java
@@ -0,0 +1,60 @@
+package de.danoeh.antennapod.playback.service;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Build;
+import android.os.IBinder;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import android.util.Log;
+import android.view.KeyEvent;
+
+import androidx.annotation.RequiresApi;
+
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.ui.appstartintent.MediaButtonStarter;
+
+@RequiresApi(api = Build.VERSION_CODES.N)
+public class QuickSettingsTileService extends TileService {
+
+ private static final String TAG = "QuickSettingsTileSvc";
+
+ @Override
+ public void onTileAdded() {
+ super.onTileAdded();
+ updateTile();
+ }
+
+ @Override
+ public void onClick() {
+ super.onClick();
+ sendBroadcast(MediaButtonStarter.createIntent(this, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
+ }
+
+ // Update the tile status when TileService.requestListeningState() is called elsewhere
+ @Override
+ public void onStartListening() {
+ super.onStartListening();
+ updateTile();
+ }
+
+ // Without this, the tile may not be in the correct state after boot
+ @Override
+ public IBinder onBind(Intent intent) {
+ TileService.requestListeningState(this, new ComponentName(this, QuickSettingsTileService.class));
+ return super.onBind(intent);
+ }
+
+ public void updateTile() {
+ Tile qsTile = getQsTile();
+ if (qsTile == null) {
+ Log.d(TAG, "Ignored call to update QS tile: getQsTile() returned null.");
+ } else {
+ boolean isPlaying = PlaybackService.isRunning
+ && PlaybackPreferences.getCurrentPlayerStatus()
+ == PlaybackPreferences.PLAYER_STATUS_PLAYING;
+ qsTile.setState(isPlaying ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
+ qsTile.updateTile();
+ }
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ExoPlayerWrapper.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ExoPlayerWrapper.java
new file mode 100644
index 000000000..22392a563
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ExoPlayerWrapper.java
@@ -0,0 +1,408 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.content.Context;
+import android.media.audiofx.LoudnessEnhancer;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.SurfaceHolder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.OptIn;
+import androidx.core.util.Consumer;
+
+import androidx.media3.common.C;
+import androidx.media3.common.PlaybackException;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.database.StandaloneDatabaseProvider;
+import androidx.media3.datasource.DataSource;
+import androidx.media3.datasource.DefaultDataSource;
+import androidx.media3.datasource.HttpDataSource;
+import androidx.media3.datasource.cache.CacheDataSource;
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor;
+import androidx.media3.datasource.cache.SimpleCache;
+import androidx.media3.datasource.okhttp.OkHttpDataSource;
+import androidx.media3.exoplayer.DefaultLoadControl;
+import androidx.media3.exoplayer.DefaultRenderersFactory;
+import androidx.media3.common.Format;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.PlaybackParameters;
+import androidx.media3.common.Player;
+import androidx.media3.exoplayer.SeekParameters;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.common.AudioAttributes;
+import androidx.media3.exoplayer.source.MediaSource;
+import androidx.media3.exoplayer.source.ProgressiveMediaSource;
+import androidx.media3.exoplayer.source.TrackGroupArray;
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
+import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
+import androidx.media3.exoplayer.trackselection.MappingTrackSelector;
+import androidx.media3.exoplayer.trackselection.TrackSelectionArray;
+
+import androidx.media3.extractor.DefaultExtractorsFactory;
+import androidx.media3.extractor.mp3.Mp3Extractor;
+import androidx.media3.ui.DefaultTrackNameProvider;
+import androidx.media3.ui.TrackNameProvider;
+import de.danoeh.antennapod.net.common.UserAgentInterceptor;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.playback.service.R;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.net.common.AntennapodHttpClient;
+import de.danoeh.antennapod.net.common.HttpCredentialEncoder;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.model.playback.Playable;
+import io.reactivex.Observable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import okhttp3.Call;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@OptIn(markerClass = UnstableApi.class)
+public class ExoPlayerWrapper {
+ public static final int BUFFERING_STARTED = -1;
+ public static final int BUFFERING_ENDED = -2;
+ private static final String TAG = "ExoPlayerWrapper";
+
+ private final Context context;
+ private final Disposable bufferingUpdateDisposable;
+ private ExoPlayer exoPlayer;
+ private MediaSource mediaSource;
+ private Runnable audioSeekCompleteListener;
+ private Runnable audioCompletionListener;
+ private Consumer<String> audioErrorListener;
+ private Consumer<Integer> bufferingUpdateListener;
+ private PlaybackParameters playbackParameters;
+ private DefaultTrackSelector trackSelector;
+ private SimpleCache simpleCache;
+ @Nullable
+ private LoudnessEnhancer loudnessEnhancer = null;
+
+ ExoPlayerWrapper(Context context) {
+ this.context = context;
+ createPlayer();
+ playbackParameters = exoPlayer.getPlaybackParameters();
+ bufferingUpdateDisposable = Observable.interval(2, TimeUnit.SECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(tickNumber -> {
+ if (bufferingUpdateListener != null) {
+ bufferingUpdateListener.accept(exoPlayer.getBufferedPercentage());
+ }
+ });
+ }
+
+ private void createPlayer() {
+ DefaultLoadControl.Builder loadControl = new DefaultLoadControl.Builder();
+ loadControl.setBufferDurationsMs(30000, 120000,
+ DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
+ DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS);
+ loadControl.setBackBuffer(UserPreferences.getRewindSecs() * 1000 + 500, true);
+ trackSelector = new DefaultTrackSelector(context);
+ exoPlayer = new ExoPlayer.Builder(context, new DefaultRenderersFactory(context))
+ .setTrackSelector(trackSelector)
+ .setLoadControl(loadControl.build())
+ .build();
+ exoPlayer.setSeekParameters(SeekParameters.EXACT);
+ exoPlayer.addListener(new Player.Listener() {
+ @Override
+ public void onPlaybackStateChanged(@Player.State int playbackState) {
+ if (audioCompletionListener != null && playbackState == Player.STATE_ENDED) {
+ audioCompletionListener.run();
+ } else if (bufferingUpdateListener != null && playbackState == Player.STATE_BUFFERING) {
+ bufferingUpdateListener.accept(BUFFERING_STARTED);
+ } else if (bufferingUpdateListener != null) {
+ bufferingUpdateListener.accept(BUFFERING_ENDED);
+ }
+ }
+
+ @Override
+ public void onPlayerError(@NonNull PlaybackException error) {
+ if (audioErrorListener != null) {
+ if (NetworkUtils.wasDownloadBlocked(error)) {
+ audioErrorListener.accept(context.getString(R.string.download_error_blocked));
+ } else {
+ Throwable cause = error.getCause();
+ if (cause instanceof HttpDataSource.HttpDataSourceException) {
+ if (cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ }
+ if (cause != null && "Source error".equals(cause.getMessage())) {
+ cause = cause.getCause();
+ }
+ audioErrorListener.accept(cause != null ? cause.getMessage() : error.getMessage());
+ }
+ }
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition,
+ @NonNull Player.PositionInfo newPosition,
+ @Player.DiscontinuityReason int reason) {
+ if (audioSeekCompleteListener != null && reason == Player.DISCONTINUITY_REASON_SEEK) {
+ audioSeekCompleteListener.run();
+ }
+ }
+
+ @Override
+ public void onAudioSessionIdChanged(int audioSessionId) {
+ initLoudnessEnhancer(audioSessionId);
+ }
+ });
+ simpleCache = new SimpleCache(new File(context.getCacheDir(), "streaming"),
+ new LeastRecentlyUsedCacheEvictor(50 * 1024 * 1024), new StandaloneDatabaseProvider(context));
+ initLoudnessEnhancer(exoPlayer.getAudioSessionId());
+ }
+
+ public int getCurrentPosition() {
+ return (int) exoPlayer.getCurrentPosition();
+ }
+
+ public float getCurrentSpeedMultiplier() {
+ return playbackParameters.speed;
+ }
+
+ public boolean getCurrentSkipSilence() {
+ return exoPlayer.getSkipSilenceEnabled();
+ }
+
+ public int getDuration() {
+ if (exoPlayer.getDuration() == C.TIME_UNSET) {
+ return Playable.INVALID_TIME;
+ }
+ return (int) exoPlayer.getDuration();
+ }
+
+ public boolean isPlaying() {
+ return exoPlayer.getPlayWhenReady();
+ }
+
+ public void pause() {
+ exoPlayer.pause();
+ }
+
+ public void prepare() throws IllegalStateException {
+ exoPlayer.setMediaSource(mediaSource, false);
+ exoPlayer.prepare();
+ }
+
+ public void release() {
+ bufferingUpdateDisposable.dispose();
+ if (exoPlayer != null) {
+ exoPlayer.release();
+ }
+ if (simpleCache != null) {
+ simpleCache.release();
+ simpleCache = null;
+ }
+ audioSeekCompleteListener = null;
+ audioCompletionListener = null;
+ audioErrorListener = null;
+ bufferingUpdateListener = null;
+ }
+
+ public void reset() {
+ exoPlayer.release();
+ if (simpleCache != null) {
+ simpleCache.release();
+ simpleCache = null;
+ }
+ createPlayer();
+ }
+
+ public void seekTo(int i) throws IllegalStateException {
+ exoPlayer.seekTo(i);
+ if (audioSeekCompleteListener != null) {
+ audioSeekCompleteListener.run();
+ }
+ }
+
+ public void setAudioStreamType(int i) {
+ AudioAttributes a = exoPlayer.getAudioAttributes();
+ AudioAttributes.Builder b = new AudioAttributes.Builder();
+ b.setContentType(i);
+ b.setFlags(a.flags);
+ b.setUsage(a.usage);
+ exoPlayer.setAudioAttributes(b.build(), false);
+ }
+
+ public void setDataSource(String s, String user, String password)
+ throws IllegalArgumentException, IllegalStateException {
+ Log.d(TAG, "setDataSource: " + s);
+ final OkHttpDataSource.Factory httpDataSourceFactory =
+ new OkHttpDataSource.Factory((Call.Factory) AntennapodHttpClient.getHttpClient())
+ .setUserAgent(UserAgentInterceptor.USER_AGENT);
+
+ if (!TextUtils.isEmpty(user) && !TextUtils.isEmpty(password)) {
+ final HashMap<String, String> requestProperties = new HashMap<>();
+ requestProperties.put(
+ "Authorization",
+ HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
+ );
+ httpDataSourceFactory.setDefaultRequestProperties(requestProperties);
+ }
+ DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, httpDataSourceFactory);
+ if (s.startsWith("http")) {
+ dataSourceFactory = new CacheDataSource.Factory()
+ .setCache(simpleCache)
+ .setUpstreamDataSourceFactory(httpDataSourceFactory);
+ }
+ DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory();
+ extractorsFactory.setConstantBitrateSeekingEnabled(true);
+ extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA);
+ ProgressiveMediaSource.Factory f = new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory);
+ final MediaItem mediaItem = MediaItem.fromUri(Uri.parse(s));
+ mediaSource = f.createMediaSource(mediaItem);
+ }
+
+ public void setDataSource(String s) throws IllegalArgumentException, IllegalStateException {
+ setDataSource(s, null, null);
+ }
+
+ public void setDisplay(SurfaceHolder sh) {
+ exoPlayer.setVideoSurfaceHolder(sh);
+ }
+
+ public void setPlaybackParams(float speed, boolean skipSilence) {
+ playbackParameters = new PlaybackParameters(speed, playbackParameters.pitch);
+ exoPlayer.setSkipSilenceEnabled(skipSilence);
+ exoPlayer.setPlaybackParameters(playbackParameters);
+ }
+
+ public void setVolume(float v, float v1) {
+ if (v > 1) {
+ exoPlayer.setVolume(1f);
+ if (loudnessEnhancer != null) {
+ loudnessEnhancer.setEnabled(true);
+ loudnessEnhancer.setTargetGain((int) (1000 * (v - 1)));
+ }
+ } else {
+ exoPlayer.setVolume(v);
+ if (loudnessEnhancer != null) {
+ loudnessEnhancer.setEnabled(false);
+ }
+ }
+ }
+
+ public void start() {
+ exoPlayer.play();
+ // Can't set params when paused - so always set it on start in case they changed
+ exoPlayer.setPlaybackParameters(playbackParameters);
+ }
+
+ public void stop() {
+ exoPlayer.stop();
+ }
+
+ public List<String> getAudioTracks() {
+ List<String> trackNames = new ArrayList<>();
+ TrackNameProvider trackNameProvider = new DefaultTrackNameProvider(context.getResources());
+ for (Format format : getFormats()) {
+ trackNames.add(trackNameProvider.getTrackName(format));
+ }
+ return trackNames;
+ }
+
+ private List<Format> getFormats() {
+ List<Format> formats = new ArrayList<>();
+ MappingTrackSelector.MappedTrackInfo trackInfo = trackSelector.getCurrentMappedTrackInfo();
+ if (trackInfo == null) {
+ return Collections.emptyList();
+ }
+ TrackGroupArray trackGroups = trackInfo.getTrackGroups(getAudioRendererIndex());
+ for (int i = 0; i < trackGroups.length; i++) {
+ formats.add(trackGroups.get(i).getFormat(0));
+ }
+ return formats;
+ }
+
+ public void setAudioTrack(int track) {
+ MappingTrackSelector.MappedTrackInfo trackInfo = trackSelector.getCurrentMappedTrackInfo();
+ if (trackInfo == null) {
+ return;
+ }
+ TrackGroupArray trackGroups = trackInfo.getTrackGroups(getAudioRendererIndex());
+ DefaultTrackSelector.SelectionOverride override = new DefaultTrackSelector.SelectionOverride(track, 0);
+ DefaultTrackSelector.Parameters params = trackSelector.buildUponParameters()
+ .setSelectionOverride(getAudioRendererIndex(), trackGroups, override).build();
+ trackSelector.setParameters(params);
+ }
+
+ private int getAudioRendererIndex() {
+ for (int i = 0; i < exoPlayer.getRendererCount(); i++) {
+ if (exoPlayer.getRendererType(i) == C.TRACK_TYPE_AUDIO) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public int getSelectedAudioTrack() {
+ TrackSelectionArray trackSelections = exoPlayer.getCurrentTrackSelections();
+ List<Format> availableFormats = getFormats();
+ for (int i = 0; i < trackSelections.length; i++) {
+ ExoTrackSelection track = (ExoTrackSelection) trackSelections.get(i);
+ if (track == null) {
+ continue;
+ }
+ if (availableFormats.contains(track.getSelectedFormat())) {
+ return availableFormats.indexOf(track.getSelectedFormat());
+ }
+ }
+ return -1;
+ }
+
+ void setOnCompletionListener(Runnable audioCompletionListener) {
+ this.audioCompletionListener = audioCompletionListener;
+ }
+
+ void setOnSeekCompleteListener(Runnable audioSeekCompleteListener) {
+ this.audioSeekCompleteListener = audioSeekCompleteListener;
+ }
+
+ void setOnErrorListener(Consumer<String> audioErrorListener) {
+ this.audioErrorListener = audioErrorListener;
+ }
+
+ int getVideoWidth() {
+ if (exoPlayer.getVideoFormat() == null) {
+ return 0;
+ }
+ return exoPlayer.getVideoFormat().width;
+ }
+
+ int getVideoHeight() {
+ if (exoPlayer.getVideoFormat() == null) {
+ return 0;
+ }
+ return exoPlayer.getVideoFormat().height;
+ }
+
+ void setOnBufferingUpdateListener(Consumer<Integer> bufferingUpdateListener) {
+ this.bufferingUpdateListener = bufferingUpdateListener;
+ }
+
+ private void initLoudnessEnhancer(int audioStreamId) {
+ if (!VolumeAdaptionSetting.isBoostSupported()) {
+ return;
+ }
+
+ LoudnessEnhancer newEnhancer = new LoudnessEnhancer(audioStreamId);
+ LoudnessEnhancer oldEnhancer = this.loudnessEnhancer;
+ if (oldEnhancer != null) {
+ newEnhancer.setEnabled(oldEnhancer.getEnabled());
+ if (oldEnhancer.getEnabled()) {
+ newEnhancer.setTargetGain((int) oldEnhancer.getTargetGain());
+ }
+ oldEnhancer.release();
+ }
+
+ this.loudnessEnhancer = newEnhancer;
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/LocalPSMP.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/LocalPSMP.java
new file mode 100644
index 000000000..e6f2668e6
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/LocalPSMP.java
@@ -0,0 +1,774 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.util.Pair;
+import android.view.SurfaceHolder;
+import androidx.annotation.NonNull;
+import androidx.media.AudioAttributesCompat;
+import androidx.media.AudioFocusRequestCompat;
+import androidx.media.AudioManagerCompat;
+import de.danoeh.antennapod.event.PlayerErrorEvent;
+import de.danoeh.antennapod.event.playback.BufferUpdateEvent;
+import de.danoeh.antennapod.event.playback.SpeedChangedEvent;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+import de.danoeh.antennapod.playback.base.RewindAfterPauseUtils;
+import de.danoeh.antennapod.playback.service.PlaybackService;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.ui.episodes.PlaybackSpeedUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Manages the MediaPlayer object of the PlaybackService.
+ */
+public class LocalPSMP extends PlaybackServiceMediaPlayer {
+ private static final String TAG = "LclPlaybackSvcMPlayer";
+
+ private final AudioManager audioManager;
+
+ private volatile PlayerStatus statusBeforeSeeking;
+ private volatile ExoPlayerWrapper mediaPlayer;
+ private volatile Playable media;
+
+ private volatile boolean stream;
+ private volatile MediaType mediaType;
+ private final AtomicBoolean startWhenPrepared;
+ private volatile boolean pausedBecauseOfTransientAudiofocusLoss;
+ private volatile Pair<Integer, Integer> videoSize;
+ private final AudioFocusRequestCompat audioFocusRequest;
+ private final Handler audioFocusCanceller;
+ private boolean isShutDown = false;
+ private CountDownLatch seekLatch;
+
+ public LocalPSMP(@NonNull Context context,
+ @NonNull PlaybackServiceMediaPlayer.PSMPCallback callback) {
+ super(context, callback);
+ this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ this.startWhenPrepared = new AtomicBoolean(false);
+ audioFocusCanceller = new Handler(Looper.getMainLooper());
+ mediaPlayer = null;
+ statusBeforeSeeking = null;
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ mediaType = MediaType.UNKNOWN;
+ videoSize = null;
+
+ AudioAttributesCompat audioAttributes = new AudioAttributesCompat.Builder()
+ .setUsage(AudioAttributesCompat.USAGE_MEDIA)
+ .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
+ .build();
+ audioFocusRequest = new AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(audioAttributes)
+ .setOnAudioFocusChangeListener(audioFocusChangeListener)
+ .setWillPauseWhenDucked(true)
+ .build();
+ }
+
+ /**
+ * 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(...)");
+ try {
+ playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately);
+ } catch (RuntimeException e) {
+ e.printStackTrace();
+ throw e;
+ }
+ }
+
+ /**
+ * 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(Playable, boolean, boolean, boolean)
+ */
+ private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) {
+ 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) {
+ callback.onPlaybackPause(media, getPosition());
+ }
+
+ if (!media.getIdentifier().equals(playable.getIdentifier())) {
+ final Playable oldMedia = media;
+ callback.onPostPlayback(oldMedia, false, false, true);
+ }
+
+ 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 {
+ callback.ensureMediaInfoLoaded(media);
+ callback.onMediaChanged(false);
+ setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media),
+ PlaybackSpeedUtils.getCurrentSkipSilencePreference(media)
+ == FeedPreferences.SkipSilence.AGGRESSIVE);
+ if (stream) {
+ if (playable instanceof FeedMedia) {
+ FeedMedia feedMedia = (FeedMedia) playable;
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ mediaPlayer.setDataSource(
+ media.getStreamUrl(),
+ preferences.getUsername(),
+ preferences.getPassword());
+ } else {
+ mediaPlayer.setDataSource(media.getStreamUrl());
+ }
+ } else if (media.getLocalFileUrl() != null && new File(media.getLocalFileUrl()).canRead()) {
+ mediaPlayer.setDataSource(media.getLocalFileUrl());
+ } else {
+ throw new IOException("Unable to read local file " + media.getLocalFileUrl());
+ }
+ UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
+ if (uiModeManager.getCurrentModeType() != Configuration.UI_MODE_TYPE_CAR) {
+ setPlayerStatus(PlayerStatus.INITIALIZED, media);
+ }
+
+ if (prepareImmediately) {
+ setPlayerStatus(PlayerStatus.PREPARING, media);
+ mediaPlayer.prepare();
+ onPrepared(startWhenPrepared);
+ }
+
+ } catch (IOException | IllegalStateException e) {
+ e.printStackTrace();
+ setPlayerStatus(PlayerStatus.ERROR, null);
+ EventBus.getDefault().postSticky(new PlayerErrorEvent(e.getLocalizedMessage()));
+ }
+ }
+
+ /**
+ * 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() {
+ if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) {
+ int focusGained = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest);
+
+ if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+ Log.d(TAG, "Audiofocus successfully requested");
+ Log.d(TAG, "Resuming/Starting playback");
+ acquireWifiLockIfNecessary();
+
+ setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media),
+ PlaybackSpeedUtils.getCurrentSkipSilencePreference(media)
+ == FeedPreferences.SkipSilence.AGGRESSIVE);
+ setVolume(1.0f, 1.0f);
+
+ if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) {
+ int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind(
+ media.getPosition(), media.getLastPlayedTime());
+ seekTo(newPosition);
+ }
+ mediaPlayer.start();
+
+ setPlayerStatus(PlayerStatus.PLAYING, media);
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ } 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) {
+ releaseWifiLockIfNecessary();
+ if (playerStatus == PlayerStatus.PLAYING) {
+ Log.d(TAG, "Pausing playback.");
+ mediaPlayer.pause();
+ setPlayerStatus(PlayerStatus.PAUSED, media, getPosition());
+
+ if (abandonFocus) {
+ abandonAudioFocus();
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ }
+ if (stream && reinit) {
+ reinit();
+ }
+ } else {
+ Log.d(TAG, "Ignoring call to pause: Player is in " + playerStatus + " state");
+ }
+ }
+
+ private void abandonAudioFocus() {
+ AudioManagerCompat.abandonAudioFocusRequest(audioManager, audioFocusRequest);
+ }
+
+ /**
+ * 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() {
+ if (playerStatus == PlayerStatus.INITIALIZED) {
+ Log.d(TAG, "Preparing media player");
+ setPlayerStatus(PlayerStatus.PREPARING, media);
+ mediaPlayer.prepare();
+ onPrepared(startWhenPrepared.get());
+ }
+ }
+
+ /**
+ * Called after media player has been prepared. This method is executed on the caller's thread.
+ */
+ private void onPrepared(final boolean startWhenPrepared) {
+ if (playerStatus != PlayerStatus.PREPARING) {
+ throw new IllegalStateException("Player is not in PREPARING state");
+ }
+ Log.d(TAG, "Resource prepared");
+
+ if (mediaType == MediaType.VIDEO) {
+ videoSize = new Pair<>(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
+ }
+
+ // TODO this call has no effect!
+ if (media.getPosition() > 0) {
+ seekTo(media.getPosition());
+ }
+
+ if (media.getDuration() <= 0) {
+ Log.d(TAG, "Setting duration of media");
+ media.setDuration(mediaPlayer.getDuration());
+ }
+ setPlayerStatus(PlayerStatus.PREPARED, media);
+
+ if (startWhenPrepared) {
+ resume();
+ }
+ }
+
+ /**
+ * Resets the media player and moves it into INITIALIZED state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ @Override
+ public void reinit() {
+ Log.d(TAG, "reinit()");
+ 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");
+ }
+ }
+
+ /**
+ * 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(int t) {
+ if (t < 0) {
+ t = 0;
+ }
+
+ if (t >= getDuration()) {
+ Log.d(TAG, "Seek reached end of file, skipping to next episode");
+ endPlayback(true, true, true, true);
+ return;
+ }
+
+ if (playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) {
+ 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);
+ statusBeforeSeeking = playerStatus;
+ setPlayerStatus(PlayerStatus.SEEKING, media, getPosition());
+ mediaPlayer.seekTo(t);
+ if (statusBeforeSeeking == PlayerStatus.PREPARED) {
+ media.setPosition(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();
+ }
+ }
+
+ /**
+ * Seek a specific position from the current position
+ *
+ * @param d offset from current position (positive or negative)
+ */
+ @Override
+ public void seekDelta(final int d) {
+ int currentPosition = getPosition();
+ if (currentPosition != Playable.INVALID_TIME) {
+ seekTo(currentPosition + d);
+ } else {
+ Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta");
+ }
+ }
+
+ /**
+ * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved.
+ */
+ @Override
+ public int getDuration() {
+ int retVal = Playable.INVALID_TIME;
+ if (playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) {
+ retVal = mediaPlayer.getDuration();
+ }
+ if (retVal <= 0 && media != null && media.getDuration() > 0) {
+ retVal = media.getDuration();
+ }
+ return retVal;
+ }
+
+ /**
+ * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved.
+ */
+ @Override
+ public int getPosition() {
+ int retVal = Playable.INVALID_TIME;
+ if (playerStatus.isAtLeast(PlayerStatus.PREPARED)) {
+ retVal = mediaPlayer.getCurrentPosition();
+ }
+ if (retVal <= 0 && media != null && media.getPosition() >= 0) {
+ retVal = media.getPosition();
+ }
+ return retVal;
+ }
+
+ @Override
+ public boolean isStartWhenPrepared() {
+ return startWhenPrepared.get();
+ }
+
+ @Override
+ public void setStartWhenPrepared(boolean startWhenPrepared) {
+ this.startWhenPrepared.set(startWhenPrepared);
+ }
+
+ /**
+ * Sets the playback speed.
+ * This method is executed on an internal executor service.
+ */
+ @Override
+ public void setPlaybackParams(final float speed, final boolean skipSilence) {
+ Log.d(TAG, "Playback speed was set to " + speed);
+ EventBus.getDefault().post(new SpeedChangedEvent(speed));
+ mediaPlayer.setPlaybackParams(speed, skipSilence);
+ }
+
+ /**
+ * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned.
+ */
+ @Override
+ public float getPlaybackSpeed() {
+ float retVal = 1;
+ if ((playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.INITIALIZED
+ || playerStatus == PlayerStatus.PREPARED)) {
+ retVal = mediaPlayer.getCurrentSpeedMultiplier();
+ }
+ return retVal;
+ }
+
+ @Override
+ public boolean getSkipSilence() {
+ boolean retVal = false;
+ if ((playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.INITIALIZED
+ || playerStatus == PlayerStatus.PREPARED)) {
+ retVal = mediaPlayer.getCurrentSkipSilence();
+ }
+ return retVal;
+ }
+
+ /**
+ * Sets the playback volume.
+ * This method is executed on an internal executor service.
+ */
+ @Override
+ public void setVolume(float volumeLeft, float volumeRight) {
+ Playable playable = getPlayable();
+ if (playable instanceof FeedMedia) {
+ FeedMedia feedMedia = (FeedMedia) playable;
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ VolumeAdaptionSetting volumeAdaptionSetting = preferences.getVolumeAdaptionSetting();
+ float adaptionFactor = volumeAdaptionSetting.getAdaptionFactor();
+ volumeLeft *= adaptionFactor;
+ volumeRight *= adaptionFactor;
+ }
+ mediaPlayer.setVolume(volumeLeft, volumeRight);
+ Log.d(TAG, "Media player volume was set to " + volumeLeft + " " + volumeRight);
+ }
+
+ @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() {
+ if (mediaPlayer != null) {
+ try {
+ clearMediaPlayerListeners();
+ if (mediaPlayer.isPlaying()) {
+ mediaPlayer.stop();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ mediaPlayer.release();
+ mediaPlayer = null;
+ playerStatus = PlayerStatus.STOPPED;
+ }
+ isShutDown = true;
+ abandonAudioFocus();
+ releaseWifiLockIfNecessary();
+ }
+
+ @Override
+ public void setVideoSurface(final SurfaceHolder surface) {
+ if (mediaPlayer != null) {
+ mediaPlayer.setDisplay(surface);
+ }
+ }
+
+ @Override
+ public void resetVideoSurface() {
+ if (mediaType == MediaType.VIDEO) {
+ Log.d(TAG, "Resetting video surface");
+ mediaPlayer.setDisplay(null);
+ reinit();
+ } else {
+ Log.e(TAG, "Resetting video surface for media of Audio type");
+ }
+ }
+
+ /**
+ * 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 (mediaPlayer != null && playerStatus != PlayerStatus.ERROR && mediaType == MediaType.VIDEO) {
+ videoSize = new Pair<>(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
+ }
+ return videoSize;
+ }
+
+ /**
+ * 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;
+ }
+
+ public List<String> getAudioTracks() {
+ return mediaPlayer.getAudioTracks();
+ }
+
+ public void setAudioTrack(int track) {
+ mediaPlayer.setAudioTrack(track);
+ }
+
+ public int getSelectedAudioTrack() {
+ return mediaPlayer.getSelectedAudioTrack();
+ }
+
+ private void createMediaPlayer() {
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ }
+ if (media == null) {
+ mediaPlayer = null;
+ playerStatus = PlayerStatus.STOPPED;
+ return;
+ }
+
+ mediaPlayer = new ExoPlayerWrapper(context);
+ mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ setMediaPlayerListeners(mediaPlayer);
+ }
+
+ private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
+
+ @Override
+ public void onAudioFocusChange(final int focusChange) {
+ if (isShutDown) {
+ return;
+ }
+ if (!PlaybackService.isRunning) {
+ abandonAudioFocus();
+ Log.d(TAG, "onAudioFocusChange: PlaybackService is no longer running");
+ return;
+ }
+
+ if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
+ Log.d(TAG, "Lost audio focus");
+ pause(true, false);
+ callback.shouldStop();
+ } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
+ && !UserPreferences.shouldPauseForFocusLoss()) {
+ if (playerStatus == PlayerStatus.PLAYING) {
+ Log.d(TAG, "Lost audio focus temporarily. Ducking...");
+ setVolume(0.25f, 0.25f);
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ }
+ } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT
+ || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
+ if (playerStatus == PlayerStatus.PLAYING) {
+ Log.d(TAG, "Lost audio focus temporarily. Pausing...");
+ mediaPlayer.pause(); // Pause without telling the PlaybackService
+ pausedBecauseOfTransientAudiofocusLoss = true;
+
+ audioFocusCanceller.removeCallbacksAndMessages(null);
+ audioFocusCanceller.postDelayed(() -> {
+ if (pausedBecauseOfTransientAudiofocusLoss) {
+ // Still did not get back the audio focus. Now actually pause.
+ pause(true, false);
+ }
+ }, 30000);
+ }
+ } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
+ Log.d(TAG, "Gained audio focus");
+ audioFocusCanceller.removeCallbacksAndMessages(null);
+ if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now
+ mediaPlayer.start();
+ } else { // we ducked => raise audio level back
+ setVolume(1.0f, 1.0f);
+ }
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ }
+ }
+ };
+
+
+ @Override
+ protected void endPlayback(final boolean hasEnded, final boolean wasSkipped,
+ final boolean shouldContinue, final boolean toStoppedState) {
+ releaseWifiLockIfNecessary();
+
+ boolean isPlaying = playerStatus == PlayerStatus.PLAYING;
+
+ // we're relying on the position stored in the Playable object for post-playback processing
+ if (media != null) {
+ int position = getPosition();
+ if (position >= 0) {
+ media.setPosition(position);
+ }
+ }
+
+ if (mediaPlayer != null) {
+ mediaPlayer.reset();
+ }
+
+ abandonAudioFocus();
+
+ final Playable currentMedia = media;
+ Playable nextMedia = null;
+
+ if (shouldContinue) {
+ // 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
+ nextMedia = callback.getNextInQueue(currentMedia);
+ if (nextMedia != null) {
+ callback.onPlaybackEnded(nextMedia.getMediaType(), false);
+ // setting media to null signals to playMediaObject() that
+ // we're taking care of post-playback processing
+ media = null;
+ playMediaObject(nextMedia, false, !nextMedia.localFileAvailable(), isPlaying, isPlaying);
+ }
+ }
+ if (shouldContinue || toStoppedState) {
+ if (nextMedia == null) {
+ callback.onPlaybackEnded(null, true);
+ stop();
+ }
+ final boolean hasNext = nextMedia != null;
+
+ callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext);
+ } else if (isPlaying) {
+ callback.onPlaybackPause(currentMedia, currentMedia.getPosition());
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private void stop() {
+ releaseWifiLockIfNecessary();
+
+ 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 stream;
+ }
+
+ private void setMediaPlayerListeners(ExoPlayerWrapper mp) {
+ if (mp == null || media == null) {
+ return;
+ }
+ mp.setOnCompletionListener(() -> endPlayback(true, false, true, true));
+ mp.setOnSeekCompleteListener(this::genericSeekCompleteListener);
+ mp.setOnBufferingUpdateListener(percent -> {
+ if (percent == ExoPlayerWrapper.BUFFERING_STARTED) {
+ EventBus.getDefault().post(BufferUpdateEvent.started());
+ } else if (percent == ExoPlayerWrapper.BUFFERING_ENDED) {
+ EventBus.getDefault().post(BufferUpdateEvent.ended());
+ } else {
+ EventBus.getDefault().post(BufferUpdateEvent.progressUpdate(0.01f * percent));
+ }
+ });
+ mp.setOnErrorListener(message -> EventBus.getDefault().postSticky(new PlayerErrorEvent(message)));
+ }
+
+ private void clearMediaPlayerListeners() {
+ mediaPlayer.setOnCompletionListener(() -> { });
+ mediaPlayer.setOnSeekCompleteListener(() -> { });
+ mediaPlayer.setOnBufferingUpdateListener(percent -> { });
+ mediaPlayer.setOnErrorListener(x -> { });
+ }
+
+ private void genericSeekCompleteListener() {
+ Log.d(TAG, "genericSeekCompleteListener");
+ if (seekLatch != null) {
+ seekLatch.countDown();
+ }
+ if (playerStatus == PlayerStatus.PLAYING) {
+ callback.onPlaybackStart(media, getPosition());
+ }
+ if (playerStatus == PlayerStatus.SEEKING) {
+ setPlayerStatus(statusBeforeSeeking, media, getPosition());
+ }
+ }
+
+ @Override
+ public boolean isCasting() {
+ return false;
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlayableUtils.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlayableUtils.java
new file mode 100644
index 000000000..beef456f9
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlayableUtils.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.playback.Playable;
+
+/**
+ * Provides utility methods for Playable objects.
+ */
+public abstract class PlayableUtils {
+ /**
+ * Saves the current position of this object.
+ *
+ * @param newPosition new playback position in ms
+ * @param timestamp current time in ms
+ */
+ public static void saveCurrentPosition(Playable playable, int newPosition, long timestamp) {
+ playable.setPosition(newPosition);
+ playable.setLastPlayedTime(timestamp);
+
+ if (playable instanceof FeedMedia) {
+ FeedMedia media = (FeedMedia) playable;
+ FeedItem item = media.getItem();
+ if (item != null && item.isNew()) {
+ DBWriter.markItemPlayed(FeedItem.UNPLAYED, item.getId());
+ }
+ if (media.getStartPosition() >= 0 && playable.getPosition() > media.getStartPosition()) {
+ media.setPlayedDuration(media.getPlayedDurationWhenStarted()
+ + playable.getPosition() - media.getStartPosition());
+ }
+ DBWriter.setFeedMediaPlaybackInformation(media);
+ }
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceNotificationBuilder.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceNotificationBuilder.java
new file mode 100644
index 000000000..75673bbed
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceNotificationBuilder.java
@@ -0,0 +1,274 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.VectorDrawable;
+import android.os.Build;
+import android.support.v4.media.session.MediaSessionCompat;
+import android.util.Log;
+import android.view.KeyEvent;
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.app.NotificationCompat;
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+import de.danoeh.antennapod.playback.service.MediaButtonReceiver;
+import de.danoeh.antennapod.playback.service.PlaybackService;
+import de.danoeh.antennapod.playback.service.R;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.ui.common.Converter;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.ui.episodes.ImageResourceUtils;
+import de.danoeh.antennapod.ui.episodes.TimeSpeedConverter;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import java.util.ArrayList;
+import java.util.concurrent.ExecutionException;
+
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+import org.apache.commons.lang3.ArrayUtils;
+
+public class PlaybackServiceNotificationBuilder {
+ private static final String TAG = "PlaybackSrvNotification";
+ private static Bitmap defaultIcon = null;
+
+ private final Context context;
+ private Playable playable;
+ private MediaSessionCompat.Token mediaSessionToken;
+ private PlayerStatus playerStatus;
+ private Bitmap icon;
+ private String position;
+
+ public PlaybackServiceNotificationBuilder(@NonNull Context context) {
+ this.context = context;
+ }
+
+ public void setPlayable(Playable playable) {
+ if (playable != this.playable) {
+ clearCache();
+ }
+ this.playable = playable;
+ }
+
+ private void clearCache() {
+ this.icon = null;
+ this.position = null;
+ }
+
+ public void updatePosition(int position, float speed) {
+ TimeSpeedConverter converter = new TimeSpeedConverter(speed);
+ this.position = Converter.getDurationStringLong(converter.convert(position));
+ }
+
+ public boolean isIconCached() {
+ return icon != null;
+ }
+
+ public void loadIcon() {
+ int iconSize = (int) (128 * context.getResources().getDisplayMetrics().density);
+ final RequestOptions options = new RequestOptions().centerCrop();
+ try {
+ icon = Glide.with(context)
+ .asBitmap()
+ .load(playable.getImageLocation())
+ .apply(options)
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (ExecutionException e) {
+ try {
+ icon = Glide.with(context)
+ .asBitmap()
+ .load(ImageResourceUtils.getFallbackImageLocation(playable))
+ .apply(options)
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (InterruptedException ignore) {
+ Log.e(TAG, "Media icon loader was interrupted");
+ } catch (Throwable tr) {
+ Log.e(TAG, "Error loading the media icon for the notification", tr);
+ }
+ } catch (InterruptedException ignore) {
+ Log.e(TAG, "Media icon loader was interrupted");
+ } catch (Throwable tr) {
+ Log.e(TAG, "Error loading the media icon for the notification", tr);
+ }
+ }
+
+ public Bitmap getCachedIcon() {
+ return icon;
+ }
+
+ private Bitmap getDefaultIcon() {
+ if (defaultIcon == null) {
+ defaultIcon = getBitmap(context, R.mipmap.ic_launcher);
+ }
+ return defaultIcon;
+ }
+
+ private static Bitmap getBitmap(VectorDrawable vectorDrawable) {
+ Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(),
+ vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ vectorDrawable.draw(canvas);
+ return bitmap;
+ }
+
+ private static Bitmap getBitmap(Context context, int drawableId) {
+ Drawable drawable = AppCompatResources.getDrawable(context, drawableId);
+ if (drawable instanceof BitmapDrawable) {
+ return ((BitmapDrawable) drawable).getBitmap();
+ } else if (drawable instanceof VectorDrawable) {
+ return getBitmap((VectorDrawable) drawable);
+ } else {
+ return null;
+ }
+ }
+
+ public Notification build() {
+ NotificationCompat.Builder notification = new NotificationCompat.Builder(context,
+ NotificationUtils.CHANNEL_ID_PLAYING);
+
+ if (playable != null) {
+ notification.setContentTitle(playable.getFeedTitle());
+ notification.setContentText(playable.getEpisodeTitle());
+ addActions(notification, mediaSessionToken, playerStatus);
+
+ if (icon != null) {
+ notification.setLargeIcon(icon);
+ } else {
+ notification.setLargeIcon(getDefaultIcon());
+ }
+
+ if (Build.VERSION.SDK_INT < 29) {
+ notification.setSubText(position);
+ }
+ } else {
+ notification.setContentTitle(context.getString(R.string.app_name));
+ notification.setContentText("Loading. If this does not go away, play any episode and contact us.");
+ }
+
+ notification.setContentIntent(getPlayerActivityPendingIntent());
+ notification.setWhen(0);
+ notification.setSmallIcon(R.drawable.ic_notification);
+ notification.setOngoing(false);
+ notification.setOnlyAlertOnce(true);
+ notification.setShowWhen(false);
+ notification.setPriority(UserPreferences.getNotifyPriority());
+ notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
+ notification.setColor(NotificationCompat.COLOR_DEFAULT);
+ return notification.build();
+ }
+
+ private PendingIntent getPlayerActivityPendingIntent() {
+ return PendingIntent.getActivity(context, R.id.pending_intent_player_activity,
+ PlaybackService.getPlayerActivityIntent(context), PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ private void addActions(NotificationCompat.Builder notification, MediaSessionCompat.Token mediaSessionToken,
+ PlayerStatus playerStatus) {
+ ArrayList<Integer> compactActionList = new ArrayList<>();
+
+ int numActions = 0; // we start and 0 and then increment by 1 for each call to addAction
+
+ PendingIntent rewindButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_REWIND, numActions);
+ notification.addAction(R.drawable.ic_notification_fast_rewind, context.getString(R.string.rewind_label),
+ rewindButtonPendingIntent);
+ compactActionList.add(numActions);
+ numActions++;
+
+ if (playerStatus == PlayerStatus.PLAYING) {
+ PendingIntent pauseButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_PAUSE, numActions);
+ notification.addAction(R.drawable.ic_notification_pause, //pause action
+ context.getString(R.string.pause_label),
+ pauseButtonPendingIntent);
+ } else {
+ PendingIntent playButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_PLAY, numActions);
+ notification.addAction(R.drawable.ic_notification_play, //play action
+ context.getString(R.string.play_label),
+ playButtonPendingIntent);
+ }
+ compactActionList.add(numActions++);
+
+ // ff follows play, then we have skip (if it's present)
+ PendingIntent ffButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, numActions);
+ notification.addAction(R.drawable.ic_notification_fast_forward, context.getString(R.string.fast_forward_label),
+ ffButtonPendingIntent);
+ compactActionList.add(numActions);
+ numActions++;
+
+ if (UserPreferences.showNextChapterOnFullNotification() && playable.getChapters() != null) {
+ PendingIntent nextChapterPendingIntent = getPendingIntentForCustomMediaAction(
+ PlaybackService.CUSTOM_ACTION_NEXT_CHAPTER, numActions);
+ notification.addAction(R.drawable.ic_notification_next_chapter, context.getString(R.string.next_chapter),
+ nextChapterPendingIntent);
+ numActions++;
+ }
+
+ if (UserPreferences.showSkipOnFullNotification()) {
+ PendingIntent skipButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_NEXT, numActions);
+ notification.addAction(R.drawable.ic_notification_skip, context.getString(R.string.skip_episode_label),
+ skipButtonPendingIntent);
+ numActions++;
+ }
+
+ PendingIntent stopButtonPendingIntent = getPendingIntentForMediaAction(
+ KeyEvent.KEYCODE_MEDIA_STOP, numActions);
+ notification.setStyle(new androidx.media.app.NotificationCompat.MediaStyle()
+ .setMediaSession(mediaSessionToken)
+ .setShowActionsInCompactView(ArrayUtils.toPrimitive(compactActionList.toArray(new Integer[0])))
+ .setShowCancelButton(true)
+ .setCancelButtonIntent(stopButtonPendingIntent));
+ }
+
+ private PendingIntent getPendingIntentForMediaAction(int keycodeValue, int requestCode) {
+ Intent intent = new Intent(context, PlaybackService.class);
+ intent.setAction("MediaCode" + keycodeValue);
+ intent.putExtra(MediaButtonReceiver.EXTRA_KEYCODE, keycodeValue);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ return PendingIntent.getForegroundService(context, requestCode, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ return PendingIntent.getService(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+ }
+
+ private PendingIntent getPendingIntentForCustomMediaAction(String action, int requestCode) {
+ Intent intent = new Intent(context, PlaybackService.class);
+ intent.setAction("MediaAction" + action);
+ intent.putExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION, action);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ return PendingIntent.getForegroundService(context, requestCode, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ return PendingIntent.getService(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+ }
+
+ public void setMediaSessionToken(MediaSessionCompat.Token mediaSessionToken) {
+ this.mediaSessionToken = mediaSessionToken;
+ }
+
+ public void setPlayerStatus(PlayerStatus playerStatus) {
+ this.playerStatus = playerStatus;
+ }
+
+ public PlayerStatus getPlayerStatus() {
+ return playerStatus;
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceStateManager.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceStateManager.java
new file mode 100644
index 000000000..0c5ed19df
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceStateManager.java
@@ -0,0 +1,52 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.app.Notification;
+import android.util.Log;
+
+import androidx.core.app.ServiceCompat;
+import de.danoeh.antennapod.playback.service.PlaybackService;
+
+public class PlaybackServiceStateManager {
+ private static final String TAG = "PlaybackSrvState";
+ private final PlaybackService playbackService;
+
+ private volatile boolean isInForeground = false;
+ private volatile boolean hasReceivedValidStartCommand = false;
+
+ public PlaybackServiceStateManager(PlaybackService playbackService) {
+ this.playbackService = playbackService;
+ }
+
+ public void startForeground(int notificationId, Notification notification) {
+ Log.d(TAG, "startForeground");
+ playbackService.startForeground(notificationId, notification);
+ isInForeground = true;
+ }
+
+ public void stopService() {
+ Log.d(TAG, "stopService");
+ stopForeground(true);
+ playbackService.stopSelf();
+ hasReceivedValidStartCommand = false;
+ }
+
+ public void stopForeground(boolean removeNotification) {
+ Log.d(TAG, "stopForeground");
+ if (isInForeground) {
+ if (removeNotification) {
+ ServiceCompat.stopForeground(playbackService, ServiceCompat.STOP_FOREGROUND_REMOVE);
+ } else {
+ ServiceCompat.stopForeground(playbackService, ServiceCompat.STOP_FOREGROUND_DETACH);
+ }
+ }
+ isInForeground = false;
+ }
+
+ public boolean hasReceivedValidStartCommand() {
+ return hasReceivedValidStartCommand;
+ }
+
+ public void validStartCommandWasReceived() {
+ this.hasReceivedValidStartCommand = true;
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceTaskManager.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceTaskManager.java
new file mode 100644
index 000000000..71e68c873
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackServiceTaskManager.java
@@ -0,0 +1,365 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Vibrator;
+import androidx.annotation.NonNull;
+import android.util.Log;
+
+import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
+import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
+import de.danoeh.antennapod.core.util.ChapterUtils;
+import de.danoeh.antennapod.ui.widget.WidgetUpdater;
+import io.reactivex.disposables.Disposable;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.model.playback.Playable;
+import io.reactivex.Completable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+
+
+/**
+ * Manages the background tasks of PlaybackSerivce, i.e.
+ * the sleep timer, the position saver, the widget updater and
+ * the queue loader.
+ * <p/>
+ * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback)
+ * to notify the PlaybackService about updates from the running tasks.
+ */
+public class PlaybackServiceTaskManager {
+ private static final String TAG = "PlaybackServiceTaskMgr";
+
+ /**
+ * Update interval of position saver in milliseconds.
+ */
+ public static final int POSITION_SAVER_WAITING_INTERVAL = 5000;
+ /**
+ * Notification interval of widget updater in milliseconds.
+ */
+ public static final int WIDGET_UPDATER_NOTIFICATION_INTERVAL = 1000;
+
+ private static final int SCHED_EX_POOL_SIZE = 2;
+ private final ScheduledThreadPoolExecutor schedExecutor;
+
+ private ScheduledFuture<?> positionSaverFuture;
+ private ScheduledFuture<?> widgetUpdaterFuture;
+ private ScheduledFuture<?> sleepTimerFuture;
+ private volatile Disposable chapterLoaderFuture;
+
+ private SleepTimer sleepTimer;
+
+ private final Context context;
+ private final PSTMCallback callback;
+
+ /**
+ * Sets up a new PSTM. This method will also start the queue loader task.
+ *
+ * @param context
+ * @param callback A PSTMCallback object for notifying the user about updates. Must not be null.
+ */
+ public PlaybackServiceTaskManager(@NonNull Context context,
+ @NonNull PSTMCallback callback) {
+ this.context = context;
+ this.callback = callback;
+ schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE, r -> {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ });
+ }
+
+ /**
+ * Starts the position saver task. If the position saver is already active, nothing will happen.
+ */
+ public synchronized void startPositionSaver() {
+ if (!isPositionSaverActive()) {
+ Runnable positionSaver = callback::positionSaverTick;
+ positionSaver = useMainThreadIfNecessary(positionSaver);
+ positionSaverFuture = schedExecutor.scheduleWithFixedDelay(positionSaver, POSITION_SAVER_WAITING_INTERVAL,
+ POSITION_SAVER_WAITING_INTERVAL, TimeUnit.MILLISECONDS);
+
+ Log.d(TAG, "Started PositionSaver");
+ } else {
+ Log.d(TAG, "Call to startPositionSaver was ignored.");
+ }
+ }
+
+ /**
+ * Returns true if the position saver is currently running.
+ */
+ public synchronized boolean isPositionSaverActive() {
+ return positionSaverFuture != null && !positionSaverFuture.isCancelled() && !positionSaverFuture.isDone();
+ }
+
+ /**
+ * Cancels the position saver. If the position saver is not running, nothing will happen.
+ */
+ public synchronized void cancelPositionSaver() {
+ if (isPositionSaverActive()) {
+ positionSaverFuture.cancel(false);
+ Log.d(TAG, "Cancelled PositionSaver");
+ }
+ }
+
+ /**
+ * Starts the widget updater task. If the widget updater is already active, nothing will happen.
+ */
+ public synchronized void startWidgetUpdater() {
+ if (!isWidgetUpdaterActive() && !schedExecutor.isShutdown()) {
+ Runnable widgetUpdater = this::requestWidgetUpdate;
+ widgetUpdater = useMainThreadIfNecessary(widgetUpdater);
+ widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater,
+ WIDGET_UPDATER_NOTIFICATION_INTERVAL, WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
+ Log.d(TAG, "Started WidgetUpdater");
+ } else {
+ Log.d(TAG, "Call to startWidgetUpdater was ignored.");
+ }
+ }
+
+ /**
+ * Retrieves information about the widget state in the calling thread and then displays it in a background thread.
+ */
+ public synchronized void requestWidgetUpdate() {
+ WidgetUpdater.WidgetState state = callback.requestWidgetState();
+ if (!schedExecutor.isShutdown()) {
+ schedExecutor.execute(() -> WidgetUpdater.updateWidget(context, state));
+ } else {
+ Log.d(TAG, "Call to requestWidgetUpdate was ignored.");
+ }
+ }
+
+ /**
+ * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
+ * cancelled first.
+ * After waitingTime has elapsed, onSleepTimerExpired() will be called.
+ *
+ * @throws java.lang.IllegalArgumentException if waitingTime <= 0
+ */
+ public synchronized void setSleepTimer(long waitingTime) {
+ if (waitingTime <= 0) {
+ throw new IllegalArgumentException("Waiting time <= 0");
+ }
+
+ Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds");
+ if (isSleepTimerActive()) {
+ sleepTimerFuture.cancel(true);
+ }
+ sleepTimer = new SleepTimer(waitingTime);
+ sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS);
+ EventBus.getDefault().post(SleepTimerUpdatedEvent.justEnabled(waitingTime));
+ }
+
+ /**
+ * Returns true if the sleep timer is currently active.
+ */
+ public synchronized boolean isSleepTimerActive() {
+ return sleepTimer != null
+ && sleepTimerFuture != null
+ && !sleepTimerFuture.isCancelled()
+ && !sleepTimerFuture.isDone()
+ && sleepTimer.getWaitingTime() > 0;
+ }
+
+ /**
+ * Disables the sleep timer. If the sleep timer is not active, nothing will happen.
+ */
+ public synchronized void disableSleepTimer() {
+ if (isSleepTimerActive()) {
+ Log.d(TAG, "Disabling sleep timer");
+ sleepTimer.cancel();
+ }
+ }
+
+ /**
+ * Restarts the sleep timer. If the sleep timer is not active, nothing will happen.
+ */
+ public synchronized void restartSleepTimer() {
+ if (isSleepTimerActive()) {
+ Log.d(TAG, "Restarting sleep timer");
+ sleepTimer.restart();
+ }
+ }
+
+ /**
+ * Returns the current sleep timer time or 0 if the sleep timer is not active.
+ */
+ public synchronized long getSleepTimerTimeLeft() {
+ if (isSleepTimerActive()) {
+ return sleepTimer.getWaitingTime();
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Returns true if the widget updater is currently running.
+ */
+ public synchronized boolean isWidgetUpdaterActive() {
+ return widgetUpdaterFuture != null && !widgetUpdaterFuture.isCancelled() && !widgetUpdaterFuture.isDone();
+ }
+
+ /**
+ * Cancels the widget updater. If the widget updater is not running, nothing will happen.
+ */
+ public synchronized void cancelWidgetUpdater() {
+ if (isWidgetUpdaterActive()) {
+ widgetUpdaterFuture.cancel(false);
+ Log.d(TAG, "Cancelled WidgetUpdater");
+ }
+ }
+
+ /**
+ * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active,
+ * it will be cancelled first.
+ * On completion, the callback's onChapterLoaded method will be called.
+ */
+ public synchronized void startChapterLoader(@NonNull final Playable media) {
+ if (chapterLoaderFuture != null) {
+ chapterLoaderFuture.dispose();
+ chapterLoaderFuture = null;
+ }
+
+ if (media.getChapters() == null) {
+ chapterLoaderFuture = Completable.create(emitter -> {
+ ChapterUtils.loadChapters(media, context, false);
+ emitter.onComplete();
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(() -> callback.onChapterLoaded(media),
+ throwable -> Log.d(TAG, "Error loading chapters: " + Log.getStackTraceString(throwable)));
+ }
+ }
+
+
+ /**
+ * Cancels all tasks. The PSTM will be in the initial state after execution of this method.
+ */
+ public synchronized void cancelAllTasks() {
+ cancelPositionSaver();
+ cancelWidgetUpdater();
+ disableSleepTimer();
+
+ if (chapterLoaderFuture != null) {
+ chapterLoaderFuture.dispose();
+ chapterLoaderFuture = null;
+ }
+ }
+
+ /**
+ * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after
+ * execution of this method.
+ */
+ public void shutdown() {
+ cancelAllTasks();
+ schedExecutor.shutdownNow();
+ }
+
+ private Runnable useMainThreadIfNecessary(Runnable runnable) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ // Called in main thread => ExoPlayer is used
+ // Run on ui thread even if called from schedExecutor
+ Handler handler = new Handler(Looper.getMainLooper());
+ return () -> handler.post(runnable);
+ } else {
+ return runnable;
+ }
+ }
+
+ /**
+ * Sleeps for a given time and then pauses playback.
+ */
+ public class SleepTimer implements Runnable {
+ private static final String TAG = "SleepTimer";
+ private static final long UPDATE_INTERVAL = 1000L;
+ public static final long NOTIFICATION_THRESHOLD = 10000;
+ private boolean hasVibrated = false;
+ private final long waitingTime;
+ private long timeLeft;
+ private ShakeListener shakeListener;
+
+ public SleepTimer(long waitingTime) {
+ super();
+ this.waitingTime = waitingTime;
+ this.timeLeft = waitingTime;
+ }
+
+ @Override
+ public void run() {
+ Log.d(TAG, "Starting");
+ long lastTick = System.currentTimeMillis();
+ EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(timeLeft));
+ while (timeLeft > 0) {
+ try {
+ Thread.sleep(UPDATE_INTERVAL);
+ } catch (InterruptedException e) {
+ Log.d(TAG, "Thread was interrupted while waiting");
+ e.printStackTrace();
+ break;
+ }
+
+ long now = System.currentTimeMillis();
+ timeLeft -= now - lastTick;
+ lastTick = now;
+
+ EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(timeLeft));
+ if (timeLeft < NOTIFICATION_THRESHOLD) {
+ Log.d(TAG, "Sleep timer is about to expire");
+ if (SleepTimerPreferences.vibrate() && !hasVibrated) {
+ Vibrator v = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
+ if (v != null) {
+ v.vibrate(500);
+ hasVibrated = true;
+ }
+ }
+ if (shakeListener == null && SleepTimerPreferences.shakeToReset()) {
+ shakeListener = new ShakeListener(context, this);
+ }
+ }
+ if (timeLeft <= 0) {
+ Log.d(TAG, "Sleep timer expired");
+ if (shakeListener != null) {
+ shakeListener.pause();
+ shakeListener = null;
+ }
+ hasVibrated = false;
+ }
+ }
+ }
+
+ public long getWaitingTime() {
+ return timeLeft;
+ }
+
+ public void restart() {
+ EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled());
+ setSleepTimer(waitingTime);
+ if (shakeListener != null) {
+ shakeListener.pause();
+ shakeListener = null;
+ }
+ }
+
+ public void cancel() {
+ sleepTimerFuture.cancel(true);
+ if (shakeListener != null) {
+ shakeListener.pause();
+ }
+ EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled());
+ }
+ }
+
+ public interface PSTMCallback {
+ void positionSaverTick();
+
+ WidgetUpdater.WidgetState requestWidgetState();
+
+ void onChapterLoaded(Playable media);
+ }
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackVolumeUpdater.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackVolumeUpdater.java
new file mode 100644
index 000000000..52d4f0fb0
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/PlaybackVolumeUpdater.java
@@ -0,0 +1,38 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+
+public class PlaybackVolumeUpdater {
+
+ public void updateVolumeIfNecessary(PlaybackServiceMediaPlayer mediaPlayer, long feedId,
+ VolumeAdaptionSetting volumeAdaptionSetting) {
+ Playable playable = mediaPlayer.getPlayable();
+
+ if (playable instanceof FeedMedia) {
+ updateFeedMediaVolumeIfNecessary(mediaPlayer, feedId, volumeAdaptionSetting, (FeedMedia) playable);
+ }
+ }
+
+ private void updateFeedMediaVolumeIfNecessary(PlaybackServiceMediaPlayer mediaPlayer, long feedId,
+ VolumeAdaptionSetting volumeAdaptionSetting, FeedMedia feedMedia) {
+ if (feedMedia.getItem().getFeed().getId() == feedId) {
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ preferences.setVolumeAdaptionSetting(volumeAdaptionSetting);
+
+ if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) {
+ forceUpdateVolume(mediaPlayer);
+ }
+ }
+ }
+
+ private void forceUpdateVolume(PlaybackServiceMediaPlayer mediaPlayer) {
+ mediaPlayer.pause(false, false);
+ mediaPlayer.resume();
+ }
+
+}
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ShakeListener.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ShakeListener.java
new file mode 100644
index 000000000..82885435d
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/ShakeListener.java
@@ -0,0 +1,62 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.util.Log;
+
+public class ShakeListener implements SensorEventListener {
+ private static final String TAG = ShakeListener.class.getSimpleName();
+
+ private Sensor mAccelerometer;
+ private SensorManager mSensorMgr;
+ private final PlaybackServiceTaskManager.SleepTimer mSleepTimer;
+ private final Context mContext;
+
+ public ShakeListener(Context context, PlaybackServiceTaskManager.SleepTimer sleepTimer) {
+ mContext = context;
+ mSleepTimer = sleepTimer;
+ resume();
+ }
+
+ private void resume() {
+ // only a precaution, the user should actually not be able to activate shake to reset
+ // when the accelerometer is not available
+ mSensorMgr = (SensorManager) mContext.getSystemService(Context.SENSOR_SERVICE);
+ if (mSensorMgr == null) {
+ throw new UnsupportedOperationException("Sensors not supported");
+ }
+ mAccelerometer = mSensorMgr.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ if (!mSensorMgr.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_UI)) { // if not supported
+ mSensorMgr.unregisterListener(this);
+ throw new UnsupportedOperationException("Accelerometer not supported");
+ }
+ }
+
+ public void pause() {
+ if (mSensorMgr != null) {
+ mSensorMgr.unregisterListener(this);
+ mSensorMgr = null;
+ }
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
+ float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
+ float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
+
+ double gForce = Math.sqrt(gX*gX + gY*gY + gZ*gZ);
+ if (gForce > 2.25) {
+ Log.d(TAG, "Detected shake " + gForce);
+ mSleepTimer.restart();
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ }
+
+} \ No newline at end of file
diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/WearMediaSession.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/WearMediaSession.java
new file mode 100644
index 000000000..95d1256ec
--- /dev/null
+++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/WearMediaSession.java
@@ -0,0 +1,24 @@
+package de.danoeh.antennapod.playback.service.internal;
+
+import android.os.Bundle;
+import android.support.v4.media.session.MediaSessionCompat;
+import android.support.v4.media.session.PlaybackStateCompat;
+
+public class WearMediaSession {
+ /**
+ * Take a custom action builder and make sure the custom action shows on Wear OS because this is the Play version
+ * of the app.
+ */
+ public static void addWearExtrasToAction(PlaybackStateCompat.CustomAction.Builder actionBuilder) {
+ Bundle actionExtras = new Bundle();
+ actionExtras.putBoolean("android.support.wearable.media.extra.CUSTOM_ACTION_SHOW_ON_WEAR", true);
+ actionBuilder.setExtras(actionExtras);
+ }
+
+ public static void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) {
+ Bundle sessionExtras = new Bundle();
+ sessionExtras.putBoolean("android.support.wearable.media.extra.RESERVE_SLOT_SKIP_TO_PREVIOUS", false);
+ sessionExtras.putBoolean("android.support.wearable.media.extra.RESERVE_SLOT_SKIP_TO_NEXT", false);
+ mediaSession.setExtras(sessionExtras);
+ }
+}
diff --git a/playback/service/src/main/res/values/ids.xml b/playback/service/src/main/res/values/ids.xml
new file mode 100644
index 000000000..2b409b64b
--- /dev/null
+++ b/playback/service/src/main/res/values/ids.xml
@@ -0,0 +1,4 @@
+<resources>
+ <item name="notification_playing" type="id" />
+ <item name="notification_streaming_confirmation" type="id" />
+</resources>
diff --git a/playback/service/src/test/java/de/danoeh/antennapod/playback/service/PlaybackVolumeUpdaterTest.java b/playback/service/src/test/java/de/danoeh/antennapod/playback/service/PlaybackVolumeUpdaterTest.java
new file mode 100644
index 000000000..9b65ac0f9
--- /dev/null
+++ b/playback/service/src/test/java/de/danoeh/antennapod/playback/service/PlaybackVolumeUpdaterTest.java
@@ -0,0 +1,228 @@
+package de.danoeh.antennapod.playback.service;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.playback.base.PlayerStatus;
+import de.danoeh.antennapod.playback.service.internal.PlaybackVolumeUpdater;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PlaybackVolumeUpdaterTest {
+
+ private static final long FEED_ID = 42;
+
+ private PlaybackServiceMediaPlayer mediaPlayer;
+
+ @Before
+ public void setUp() {
+ mediaPlayer = mock(PlaybackServiceMediaPlayer.class);
+ }
+
+ @Test
+ public void noChangeIfNoFeedMediaPlaying() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PAUSED);
+
+ Playable noFeedMedia = mock(Playable.class);
+ when(mediaPlayer.getPlayable()).thenReturn(noFeedMedia);
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.OFF);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void noChangeIfPlayerStatusIsError() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.ERROR);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.OFF);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void noChangeIfPlayerStatusIsIndeterminate() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.INDETERMINATE);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.OFF);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void noChangeIfPlayerStatusIsStopped() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.STOPPED);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.OFF);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void noChangeIfPlayableIsNoItemOfAffectedFeed() {
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PLAYING);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ when(feedMedia.getItem().getFeed().getId()).thenReturn(FEED_ID + 1);
+
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.OFF);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPaused() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PAUSED);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPrepared() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PREPARED);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsInitializing() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.INITIALIZING);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsPreparing() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PREPARING);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesForLoadedFeedMediaIfPlayerStatusIsSeeking() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.SEEKING);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.LIGHT_REDUCTION);
+
+ verify(mediaPlayer, never()).pause(anyBoolean(), anyBoolean());
+ verify(mediaPlayer, never()).resume();
+ }
+
+ @Test
+ public void updatesPreferencesAndForcesVolumeChangeForLoadedFeedMediaIfPlayerStatusIsPlaying() {
+ PlaybackVolumeUpdater playbackVolumeUpdater = new PlaybackVolumeUpdater();
+
+ when(mediaPlayer.getPlayerStatus()).thenReturn(PlayerStatus.PLAYING);
+
+ FeedMedia feedMedia = mockFeedMedia();
+ when(mediaPlayer.getPlayable()).thenReturn(feedMedia);
+ FeedPreferences feedPreferences = feedMedia.getItem().getFeed().getPreferences();
+
+ playbackVolumeUpdater.updateVolumeIfNecessary(mediaPlayer, FEED_ID, VolumeAdaptionSetting.HEAVY_REDUCTION);
+
+ verify(feedPreferences, times(1)).setVolumeAdaptionSetting(VolumeAdaptionSetting.HEAVY_REDUCTION);
+
+ verify(mediaPlayer, times(1)).pause(false, false);
+ verify(mediaPlayer, times(1)).resume();
+ }
+
+ private FeedMedia mockFeedMedia() {
+ FeedMedia feedMedia = mock(FeedMedia.class);
+ FeedItem feedItem = mock(FeedItem.class);
+ Feed feed = mock(Feed.class);
+ FeedPreferences feedPreferences = mock(FeedPreferences.class);
+
+ when(feedMedia.getItem()).thenReturn(feedItem);
+ when(feedItem.getFeed()).thenReturn(feed);
+ when(feed.getId()).thenReturn(FEED_ID);
+ when(feed.getPreferences()).thenReturn(feedPreferences);
+ return feedMedia;
+ }
+}