diff options
author | ByteHamster <ByteHamster@users.noreply.github.com> | 2024-03-29 21:05:02 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-29 21:05:02 +0100 |
commit | 8accb546850e5d66aaab310c4cd4a528c058386e (patch) | |
tree | cc6ea8bd7304db5d2dae0467bae04d491137cef2 /playback | |
parent | 2fd73b148d012fba7308c86494689103b8aaace4 (diff) | |
download | AntennaPod-8accb546850e5d66aaab310c4cd4a528c058386e.zip |
Move playback service to module (#7042)
Diffstat (limited to 'playback')
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; + } +} |