// -*- mode: java; c-basic-offset: 2; -*-
/// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2018 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0

package com.google.appinventor.components.runtime;

import com.google.appinventor.components.annotations.DesignerComponent;
import com.google.appinventor.components.annotations.DesignerProperty;
import com.google.appinventor.components.annotations.PropertyCategory;
import com.google.appinventor.components.annotations.SimpleEvent;
import com.google.appinventor.components.annotations.SimpleFunction;
import com.google.appinventor.components.annotations.SimpleObject;
import com.google.appinventor.components.annotations.SimpleProperty;
import com.google.appinventor.components.annotations.UsesPermissions;
import com.google.appinventor.components.common.ComponentCategory;
import com.google.appinventor.components.common.PropertyTypeConstants;
import com.google.appinventor.components.common.YaVersion;
import com.google.appinventor.components.runtime.errors.PermissionException;
import com.google.appinventor.components.runtime.util.ErrorMessages;
import com.google.appinventor.components.runtime.util.MediaUtil;
import com.google.appinventor.components.runtime.util.SdkLevel;

import android.content.Context;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Handler;
import android.os.Vibrator;
import android.util.Log;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * Multimedia component that plays sounds and optionally vibrates.  A
 * sound is specified via filename.  See also
 * {@link android.media.SoundPool}.
 *
 * @author sharon@google.com (Sharon Perl)
 * @author hal@mit.edu (Hal Abelson) added wait for load to complete
 */
@DesignerComponent(version = YaVersion.SOUND_COMPONENT_VERSION,
    description = "<p>A multimedia component that plays sound " +
    "files and optionally vibrates for the number of milliseconds " +
    "(thousandths of a second) specified in the Blocks Editor.  The name of " +
    "the sound file to play can be specified either in the Designer or in " +
    "the Blocks Editor.</p> <p>For supported sound file formats, see " +
    "<a href=\"http://developer.android.com/guide/appendix/media-formats.html\"" +
    " target=\"_blank\">Android Supported Media Formats</a>.</p>" +
    "<p>This <code>Sound</code> component is best for short sound files, such as sound " +
    "effects, while the <code>Player</code> component is more efficient for " +
    "longer sounds, such as songs.</p>" +
    "<p>You might get an error if you attempt to play a sound " +
    "immeditely after setting the source.</p>",
    category = ComponentCategory.MEDIA,
    nonVisible = true,
    iconName = "images/soundEffect.png")
@SimpleObject
@UsesPermissions(permissionNames = "android.permission.VIBRATE, android.permission.INTERNET")
public class Sound extends AndroidNonvisibleComponent
    implements Component, OnResumeListener, OnStopListener, OnDestroyListener, Deleteable {

  private boolean loadComplete;    // did the sound finish loading

  // The purpose of this class is to avoid getting rejected by the Android verifier when the
  // Sound component code is loaded into a device with API level less than 8, where the verifier
  // will reject OnLoadCompleteListener.  We do this trick by putting
  // the use of OnLoadCompleteListener in the class OnLoadHelper and arranging (see below) for
  // the class to be compiled only if the API level is at least 8.
  private class OnLoadHelper {
    public void setOnloadCompleteListener (SoundPool soundPool) {
      soundPool.setOnLoadCompleteListener(new android.media.SoundPool.OnLoadCompleteListener() {
        public void onLoadComplete(SoundPool soundPool, int sampleId, int status) {
          loadComplete = true;
        }
      });
    }
  }

  private static final int MAX_STREAMS = 10;

  // max number of consecutive delays to wait for a sound to load
  private static final int MAX_PLAY_DELAY_RETRIES = 10;
  // number of ms in each delay before retrying
  private static final int PLAY_DELAY_LENGTH = 50;

  private static final float VOLUME_FULL = 1.0f;
  private static final int LOOP_MODE_NO_LOOP = 0;
  private static final float PLAYBACK_RATE_NORMAL = 1.0f;
  private SoundPool soundPool;

  // soundMap maps sounds (assets, etc) that are loaded into soundPool to their respective
  // soundIds.
  private final Map<String, Integer> soundMap;

  // We will wait for Sound loading to complete before trying to play, but only
  // if the API level is at least 8, because onLoadCompleteListener is not available
  // in earlier APIs. For those early systems, attempting to play a sound before it is loaded
  // will fail to play the sound and there will be no retry, although there might be a "cannot
  // play" error.
  private final boolean waitForLoadToComplete = (SdkLevel.getLevel() >= SdkLevel.LEVEL_FROYO);

  private String sourcePath;              // name of source
  private int soundId;                    // id of sound in the soundPool
  private int streamId;                   // stream id returned from last call to SoundPool.play
  private int minimumInterval;            // minimum interval between Play() calls
  private long timeLastPlayed;            // the system time when Play() was last called
  private final Vibrator vibe;
  private final Handler playWaitHandler = new Handler();

  //save a pointer to this Sound component to use in the error in postDelayed below
  private final Component thisComponent;


  public Sound(ComponentContainer container) {
    super(container.$form());
    thisComponent = this;
    soundPool = new SoundPool(MAX_STREAMS, AudioManager.STREAM_MUSIC, 0);
    soundMap = new HashMap<String, Integer>();
    vibe = (Vibrator) form.getSystemService(Context.VIBRATOR_SERVICE);
    sourcePath = "";
    loadComplete = true;  //nothing to wait for until we attempt to load
    form.registerForOnResume(this);
    form.registerForOnStop(this);
    form.registerForOnDestroy(this);

    // Make volume buttons control media, not ringer.
    form.setVolumeControlStream(AudioManager.STREAM_MUSIC);

    // Default property values
    MinimumInterval(500);

    if (waitForLoadToComplete) {
      new OnLoadHelper().setOnloadCompleteListener(soundPool);
    }
  }



  /**
   * Returns the sound's filename.
   */
  @SimpleProperty(
      category = PropertyCategory.BEHAVIOR,
      description = "The name of the sound file.  Only certain " +
      "formats are supported.  See http://developer.android.com/guide/appendix/media-formats.html.")
  public String Source() {
    return sourcePath;
  }

  /**
   * Sets the sound source
   *
   * <p/>See {@link MediaUtil#determineMediaSource} for information about what
   * a path can be.
   *
   * @param path  the path to the sound source
   */
  @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_ASSET,
      defaultValue = "")
  @SimpleProperty
  public void Source(String path) {
    sourcePath = (path == null) ? "" : path;

    // Clear the previous sound.
    if (streamId != 0) {
      soundPool.stop(streamId);
      streamId = 0;
    }
    soundId = 0;

    if (sourcePath.length() != 0) {
      Integer existingSoundId = soundMap.get(sourcePath);
      if (existingSoundId != null) {
        soundId = existingSoundId;

      } else {
        Log.i("Sound", "No existing sound with path " + sourcePath + ".");
        try {
          int newSoundId = MediaUtil.loadSoundPool(soundPool, form, sourcePath);
          if (newSoundId != 0) {
            soundMap.put(sourcePath, newSoundId);
            Log.i("Sound", "Successfully began loading sound: setting soundId to " + newSoundId + ".");
            soundId = newSoundId;
            // set flag to show that loading has begun
            loadComplete = false;
          } else {
            form.dispatchErrorOccurredEvent(this, "Source",
                ErrorMessages.ERROR_UNABLE_TO_LOAD_MEDIA, sourcePath);
          }
        } catch (PermissionException e) {
          form.dispatchPermissionDeniedEvent(this, "Source", e);
        } catch (IOException e) {
          form.dispatchErrorOccurredEvent(this, "Source",
              ErrorMessages.ERROR_UNABLE_TO_LOAD_MEDIA, sourcePath);
        }
      }
    }
  }

  /**
   * Returns the minimum interval required between calls to Play(), in
   * milliseconds.
   * Once the sound starts playing, all further Play() calls will be ignored
   * until the interval has elapsed.
   * @return  minimum interval in ms
   */
  @SimpleProperty(
      category = PropertyCategory.BEHAVIOR,
      description = "The minimum interval, in milliseconds, between sounds.  If you play a sound, " +
      "all further Play() calls will be ignored until the interval has elapsed.")
  public int MinimumInterval() {
    return minimumInterval;
  }

  /**
   * Specify the minimum interval required between calls to Play(), in
   * milliseconds.
   * Once the sound starts playing, all further Play() calls will be ignored
   * until the interval has elapsed.
   * @param interval  minimum interval in ms
   */
  @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_NON_NEGATIVE_INTEGER,
      defaultValue = "500")
  @SimpleProperty
  public void MinimumInterval(int interval) {
    minimumInterval = interval;
  }


  // number of retries remaining before signaling an error
  private int delayRetries;

  /**
   * Plays the sound.
   */
  @SimpleFunction(description = "Plays the sound specified by the Source property.")
  public void Play() {
    if (soundId != 0) {
      long currentTime = System.currentTimeMillis();
      if (timeLastPlayed == 0 || currentTime >= timeLastPlayed + minimumInterval) {
        timeLastPlayed = currentTime;
        delayRetries =  MAX_PLAY_DELAY_RETRIES;
        playWhenLoadComplete();
      } else {
        // fail silently
        Log.i("Sound", "Unable to play because MinimumInterval has not elapsed since last play.");
      }
    } else {
      // Alert the user that the sound is bad, but would need to look in the log to distinguish
      // this error from the UNABLE_TO_PLAY_MEDIA error in playAndCheck.
      Log.i("Sound", "Sound Id was 0. Did you remember to set the Source property?");
      form.dispatchErrorOccurredEvent(this, "Play",
          ErrorMessages.ERROR_UNABLE_TO_PLAY_MEDIA, sourcePath);
    }
  }

  // Attempt to play the sound, possibly after a delay to allow the sound to load.
  private void playWhenLoadComplete() {
    if (loadComplete || !waitForLoadToComplete) {
      playAndCheckResult();
    } else {
      Log.i("Sound", "Sound not ready:  retrying.  Remaining retries = " + delayRetries);
      // if the sound wasn't ready we retry after a delay. We implement the delay by posting
      // to a separate handler: using a loop with a sleep might seem simpler, but it would block
      // the UI thread.
      playWaitHandler.postDelayed(new Runnable() {
        @Override
        public void run() {
          if (loadComplete) {
            playAndCheckResult();
          } else if (delayRetries > 0) {
            delayRetries--;
            playWhenLoadComplete();
          } else {
            form.dispatchErrorOccurredEvent(thisComponent, "Play",
                ErrorMessages.ERROR_SOUND_NOT_READY, sourcePath);
          }
        }
      }, PLAY_DELAY_LENGTH);
    }
  }

  private void playAndCheckResult() {
    streamId = soundPool.play(soundId, VOLUME_FULL, VOLUME_FULL, 0, LOOP_MODE_NO_LOOP,
        PLAYBACK_RATE_NORMAL);
  Log.i("Sound", "SoundPool.play returned stream id " + streamId);
  if (streamId == 0) {
    form.dispatchErrorOccurredEvent(this, "Play",
        ErrorMessages.ERROR_UNABLE_TO_PLAY_MEDIA, sourcePath);
}
  }


  /**
   * Pauses playing the sound if it is being played.
   */
  @SimpleFunction(description = "Pauses playing the sound if it is being played.")
  public void Pause() {
    if (streamId != 0) {
      soundPool.pause(streamId);
    } else {
      Log.i("Sound", "Unable to pause. Did you remember to call the Play function?");
    }
  }

  /**
   * Resumes playing the sound after a pause.
   */
  @SimpleFunction(description = "Resumes playing the sound after a pause.")
  public void Resume() {
    if (streamId != 0) {
      soundPool.resume(streamId);
    } else {
      Log.i("Sound", "Unable to resume. Did you remember to call the Play function?");
    }
  }

  /**
   * Stops playing the sound if it is being played.
   */
@SimpleFunction(description = "Stops playing the sound if it is being played.")
  public void Stop() {
    if (streamId != 0) {
      soundPool.stop(streamId);
      streamId = 0;
    } else {
      Log.i("Sound", "Unable to stop. Did you remember to call the Play function?");
    }
  }

  /**
   * Vibrates for the specified number of milliseconds.
   */
  @SimpleFunction(description = "Vibrates for the specified number of milliseconds.")
  public void Vibrate(int millisecs) {
    vibe.vibrate(millisecs);
  }

  @SimpleEvent(description = "The SoundError event is no longer used. " +
      "Please use the Screen.ErrorOccurred event instead.",
      userVisible = false)
  public void SoundError(String message) {
  }

  // OnStopListener implementation

  @Override
  public void onStop() {
    Log.i("Sound", "Got onStop");
    if (streamId != 0) {
      soundPool.pause(streamId);
    }
  }

  // OnResumeListener implementation

  @Override
  public void onResume() {
    Log.i("Sound", "Got onResume");
    if (streamId != 0) {
      soundPool.resume(streamId);
    }
  }

  // OnDestroyListener implementation

  @Override
  public void onDestroy() {
    prepareToDie();
  }

  // Deletable implementation

  @Override
  public void onDelete() {
    prepareToDie();
  }

  private void prepareToDie() {
    if (streamId != 0) {
      soundPool.stop(streamId);
      soundPool.unload(streamId);
    }
    soundPool.release();
    vibe.cancel();
    // The documentation for SoundPool suggests setting the reference to null;
    soundPool = null;
  }
}
