// -*- mode: java; c-basic-offset: 2; -*- /// Copyright 2009-2011 Google, All Rights reserved // Copyright 2011-2012 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.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 (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; } }