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.car.app.connection.CarConnection; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; 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.ui.common.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 LiveData androidAutoConnectionState; private boolean androidAutoConnected = false; private Observer androidAutoConnectionObserver; 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); androidAutoConnectionState = new CarConnection(this).getType(); androidAutoConnectionObserver = connectionState -> { androidAutoConnected = connectionState == CarConnection.CONNECTION_TYPE_PROJECTION; }; androidAutoConnectionState.observeForever(androidAutoConnectionObserver); ContextCompat.registerReceiver(this, autoStateUpdated, new IntentFilter("com.google.android.gms.car.media.STATUS"), ContextCompat.RECEIVER_EXPORTED); ContextCompat.registerReceiver(this, shutdownReceiver, new IntentFilter(PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE), ContextCompat.RECEIVER_NOT_EXPORTED); registerReceiver(headsetDisconnected, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); 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(); androidAutoConnectionState.removeObserver(androidAutoConnectionObserver); 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.>create(emitter -> { List 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> 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 loadChildrenSynchronous(@NonNull String parentId) { List 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 feeds = DBReader.getFeedList(); for (Feed feed : feeds) { mediaItems.add(createBrowsableMediaItemForFeed(feed)); } return mediaItems; } List 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]); feedItems = DBReader.getFeed(feedId, true, 0, MAX_ANDROID_AUTO_EPISODES_PER_FEED).getItems(); } 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.getPlayerStatus(); 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); // only notify the user about an unknown key event if it is actually doing something if (info.getPlayable() != null && info.getPlayerStatus() == PlayerStatus.PLAYING) { 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.getPlayerStatus()); switch (newInfo.getPlayerStatus()) { case INITIALIZED: if (mediaPlayer.getPSMPInfo().getPlayable() != null) { PlaybackPreferences.writeMediaPlaying(mediaPlayer.getPSMPInfo().getPlayable()); } updateNotificationAndMediaSession(newInfo.getPlayable()); break; case PREPARED: if (mediaPlayer.getPSMPInfo().getPlayable() != null) { PlaybackPreferences.writeMediaPlaying(mediaPlayer.getPSMPInfo().getPlayable()); } taskManager.startChapterLoader(newInfo.getPlayable()); break; case PAUSED: updateNotificationAndMediaSession(newInfo.getPlayable()); PlaybackPreferences.setCurrentPlayerStatus(PlaybackPreferences.PLAYER_STATUS_PAUSED); if (!isCasting) { stateManager.stopForeground(!UserPreferences.isPersistNotify()); } cancelPositionObserver(); break; case STOPPED: //writePlaybackPreferencesNoMediaPlaying(); //stopService(); break; case PLAYING: PlaybackPreferences.setCurrentPlayerStatus(PlaybackPreferences.PLAYER_STATUS_PLAYING); saveCurrentPosition(true, null, Playable.INVALID_TIME); recreateMediaSessionIfNeeded(); updateNotificationAndMediaSession(newInfo.getPlayable()); 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 (androidAutoConnected) { Log.i(TAG, "Android Auto is connected, sleep timer will not be auto-enabled"); autoEnableByTime = false; } if (newInfo.getOldPlayerStatus() != null && newInfo.getOldPlayerStatus() != 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) { try { TileService.requestListeningState(getApplicationContext(), new ComponentName(getApplicationContext(), QuickSettingsTileService.class)); } catch (IllegalArgumentException e) { Log.d(TAG, "Skipping quick settings tile setup"); } } 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. *

* 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 autoDeleteEnabledGlobally = UserPreferences.isAutoDelete() && (!item.getFeed().isLocalFeed() || UserPreferences.isAutoDeleteLocal()); boolean shouldAutoDelete = action == FeedPreferences.AutoDeleteAction.ALWAYS || (action == FeedPreferences.AutoDeleteAction.GLOBAL && autoDeleteEnabledGlobally); 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.getPlayerStatus() == PlayerStatus.PLAYING) { isPlaying = true; } if (info.getPlayable() != null) { Intent i = new Intent(whatChanged); i.putExtra("id", 1L); i.putExtra("artist", ""); i.putExtra("album", info.getPlayable().getFeedTitle()); i.putExtra("track", info.getPlayable().getEpisodeTitle()); i.putExtra("playing", isPlaying); i.putExtra("duration", (long) info.getPlayable().getDuration()); i.putExtra("position", (long) info.getPlayable().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)) { stateManager.stopService(); PlaybackPreferences.writeNoMediaPlaying(); EventBus.getDefault().post(new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)); EventBus.getDefault().post(new PlayerStatusEvent()); } } }; @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 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 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 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 chapters = mediaPlayer.getPlayable().getChapters(); if (chapters == null) { // No chapters, just fallback to next episode mediaPlayer.skip(); return; } int nextChapter = Chapter.getAfterPosition(chapters, 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 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()); } } } }; }