content: package allen.town.podcast.core.service.playback;
  
  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 allen.town.podcast.event.playback.SleepTimerUpdatedEvent;
  import allen.town.podcast.core.pref.SleepTimerPreferences;
  import allen.town.podcast.core.util.ChapterUtils;
  import allen.town.podcast.core.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 allen.town.podcast.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 = "PlaybackTaskManager";
  
      /**
       * 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, "begin position saver");
          } else {
              Log.d(TAG, "start position saver 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 widget updater");
          } else {
              Log.d(TAG, "widget updater 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, "request widget update 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, "sleep timer -> " + 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, "try to disable 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);
                  emitter.onComplete();
              })
                      .subscribeOn(Schedulers.io())
                      .observeOn(AndroidSchedulers.mainThread())
                      .subscribe(() -> callback.onChapterLoaded(media),
                              throwable -> Log.d(TAG, "failed to load 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.
       */
      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 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);
      }
  }
  