// -*- 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.ComponentConstants; 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.FullScreenVideoUtil; 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.MediaPlayer; import android.media.MediaPlayer.OnCompletionListener; import android.media.MediaPlayer.OnErrorListener; import android.media.MediaPlayer.OnPreparedListener; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.View; import android.widget.MediaController; import android.widget.VideoView; import java.io.IOException; /** * TODO: This copies the video from the application's asset directory to a temp * file, because the Android VideoView class can't handle assets. * Marco plans to include that feature in future releases. */ /** * TODO: Check that player is prepared (is this necessary?) See if we need to * use isPlaying, onErrorListener, and onPreparedListener. */ /** * TODO: Set up the touch (and trackball?) Simple event handlers so that they * interact well with the videoView implementation, i.e., defining handlers * should (probably) override videoView making the Mediacontroller appear. */ /** * TODO: Remove writes of state debugging info to the log after we're sure * things are working solidly. */ /** * TODO: The resizing of the VideoPlayer at runtime does not work well on some * devices such as the Motorola Droid. The behavior is almost random when resizing * the VideoPlayer on such devices. When App Inventor includes the features to * restrict certain devices, the VideoPlayer should be updated. */ /** * Implementation of VideoPlayer, using {@link android.widget.VideoView}. * * @author halabelson@google.com (Hal Abelson) */ @DesignerComponent( version = YaVersion.VIDEOPLAYER_COMPONENT_VERSION, description = "A multimedia component capable of playing videos. " + "When the application is run, the VideoPlayer will be displayed as a " + "rectangle on-screen. If the user touches the rectangle, controls will " + "appear to play/pause, skip ahead, and skip backward within the video. " + "The application can also control behavior by calling the " + "<code>Start</code>, <code>Pause</code>, and <code>SeekTo</code> methods. " + "<p>Video files should be in " + "3GPP (.3gp) or MPEG-4 (.mp4) formats. For more details about legal " + "formats, see " + "<a href=\"http://developer.android.com/guide/appendix/media-formats.html\"" + " target=\"_blank\">Android Supported Media Formats</a>.</p>" + "<p>App Inventor for Android only permits video files under 1 MB and " + "limits the total size of an application to 5 MB, not all of which is " + "available for media (video, audio, and sound) files. If your media " + "files are too large, you may get errors when packaging or installing " + "your application, in which case you should reduce the number of media " + "files or their sizes. Most video editing software, such as Windows " + "Movie Maker and Apple iMovie, can help you decrease the size of videos " + "by shortening them or re-encoding the video into a more compact format.</p>" + "<p>You can also set the media source to a URL that points to a streaming video, " + "but the URL must point to the video file itself, not to a program that plays the video.", category = ComponentCategory.MEDIA) @SimpleObject @UsesPermissions(permissionNames = "android.permission.INTERNET") public final class VideoPlayer extends AndroidViewComponent implements OnDestroyListener, Deleteable, OnCompletionListener, OnErrorListener, OnPreparedListener { /* * Video clip with player controls (touch it to activate) */ private final ResizableVideoView videoView; private String sourcePath; // name of media source private boolean inFullScreen = false; // The VideoView does not always start playing if Start is called // shortly after the source is set. These flags are used to fix this // problem. private boolean mediaReady = false; private boolean delayedStart = false; private MediaPlayer mPlayer; private final Handler androidUIHandler = new Handler(); /** * Creates a new VideoPlayer component. * * @param container */ public VideoPlayer(ComponentContainer container) { super(container); container.$form().registerForOnDestroy(this); videoView = new ResizableVideoView(container.$context()); videoView.setMediaController(new MediaController(container.$context())); videoView.setOnCompletionListener(this); videoView.setOnErrorListener(this); videoView.setOnPreparedListener(this); // add the component to the designated container container.$add(this); // set a default size container.setChildWidth(this, ComponentConstants.VIDEOPLAYER_PREFERRED_WIDTH); container.setChildHeight(this, ComponentConstants.VIDEOPLAYER_PREFERRED_HEIGHT); // Make volume buttons control media, not ringer. container.$form().setVolumeControlStream(AudioManager.STREAM_MUSIC); sourcePath = ""; } @Override public View getView() { return videoView; } /** * Sets the video source. * * <p/> * See {@link MediaUtil#determineMediaSource} for information about what a * path can be. * * @param path * the path to the video source */ @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_ASSET, defaultValue = "") @SimpleProperty( description = "The \"path\" to the video. Usually, this will be the " + "name of the video file, which should be added in the Designer.", category = PropertyCategory.BEHAVIOR) public void Source(String path) { if (inFullScreen) { container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_SOURCE, this, path); } else { sourcePath = (path == null) ? "" : path; // The source may change for the MediaPlayer, and // getVideoWidth or getVideoHeight may be called // creating an error in ResizableVideoView. videoView.invalidateMediaPlayer(true); // Clear the previous video. if (videoView.isPlaying()) { videoView.stopPlayback(); } videoView.setVideoURI(null); videoView.clearAnimation(); if (sourcePath.length() > 0) { Log.i("VideoPlayer", "Source path is " + sourcePath); try { mediaReady = false; MediaUtil.loadVideoView(videoView, container.$form(), sourcePath); } catch (IOException e) { container.$form().dispatchErrorOccurredEvent(this, "Source", ErrorMessages.ERROR_UNABLE_TO_LOAD_MEDIA, sourcePath); return; } Log.i("VideoPlayer", "loading video succeeded"); } } } /** * Plays the media specified by the source. These won't normally be used in * the most elementary applications, because videoView brings up its own * player controls when the video is touched. */ @SimpleFunction(description = "Starts playback of the video.") public void Start() { Log.i("VideoPlayer", "Calling Start"); if (inFullScreen) { container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_PLAY, this, null); } else { if (mediaReady) { videoView.start(); } else { delayedStart = true; } } } /** * Sets the volume property to a number between 0 and 100. * * @param vol the desired volume level */ @DesignerProperty( editorType = PropertyTypeConstants.PROPERTY_TYPE_NON_NEGATIVE_FLOAT, defaultValue = "50") @SimpleProperty( description = "Sets the volume to a number between 0 and 100. " + "Values less than 0 will be treated as 0, and values greater than 100 " + "will be treated as 100.") public void Volume(int vol) { // clip volume to range [0, 100] vol = Math.max(vol, 0); vol = Math.min(vol, 100); if (mPlayer != null) { mPlayer.setVolume(((float) vol) / 100, ((float) vol) / 100); } } /** * Method for starting the VideoPlayer once the media has been loaded. * Not visible to users. */ public void delayedStart() { delayedStart = true; Start(); } @SimpleFunction( description = "Pauses playback of the video. Playback can be resumed " + "at the same location by calling the <code>Start</code> method.") public void Pause() { Log.i("VideoPlayer", "Calling Pause"); if (inFullScreen) { container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_PAUSE, this, null); delayedStart = false; } else { delayedStart = false; videoView.pause(); } } @SimpleFunction( description = "Seeks to the requested time (specified in milliseconds) in the video. " + "If the video is paused, the frame shown will not be updated by the seek. " + "The player can jump only to key frames in the video, so seeking to times that " + "differ by short intervals may not actually move to different frames.") public void SeekTo(int ms) { Log.i("VideoPlayer", "Calling SeekTo"); if (ms < 0) { ms = 0; } if (inFullScreen) { container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_SEEK, this, ms); } else { // There is no harm if the milliseconds is longer than the duration. videoView.seekTo(ms); } } @SimpleFunction( description = "Returns duration of the video in milliseconds.") public int GetDuration() { Log.i("VideoPlayer", "Calling GetDuration"); if (inFullScreen) { Bundle result = container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_DURATION, this, null); if (result.getBoolean(FullScreenVideoUtil.ACTION_SUCESS)) { return result.getInt(FullScreenVideoUtil.ACTION_DATA); } else { return 0; } } else { return videoView.getDuration(); } } // OnCompletionListener implementation @Override public void onCompletion(MediaPlayer m) { Completed(); } /** * Indicates that the video has reached the end */ @SimpleEvent public void Completed() { EventDispatcher.dispatchEvent(this, "Completed"); } // OnErrorListener implementation @Override public boolean onError(MediaPlayer m, int what, int extra) { // The ResizableVideoView onMeasure method attempts to use the MediaPlayer // to measure // the VideoPlayer; but in the event of an error, the MediaPlayer // may report dimensions of zero video width and height. // Since VideoPlayer currently (7/10/2012) sets its size always // to some non-zero number, the MediaPlayer is invalidated here // to prevent onMeasure from setting width and height as zero. videoView.invalidateMediaPlayer(true); delayedStart = false; mediaReady = false; Log.e("VideoPlayer", "onError: what is " + what + " 0x" + Integer.toHexString(what) + ", extra is " + extra + " 0x" + Integer.toHexString(extra)); container.$form().dispatchErrorOccurredEvent(this, "Source", ErrorMessages.ERROR_UNABLE_TO_LOAD_MEDIA, sourcePath); return true; } @Override public void onPrepared(MediaPlayer newMediaPlayer) { mediaReady = true; delayedStart = false; mPlayer = newMediaPlayer; videoView.setMediaPlayer(mPlayer, true); if (delayedStart) { Start(); } } @SimpleEvent(description = "The VideoPlayerError event is no longer used. " + "Please use the Screen.ErrorOccurred event instead.", userVisible = false) public void VideoPlayerError(String message) { } // OnDestroyListener implementation @Override public void onDestroy() { prepareToDie(); } // Deleteable implementation @Override public void onDelete() { prepareToDie(); } private void prepareToDie() { if (videoView.isPlaying()) { videoView.stopPlayback(); } videoView.setVideoURI(null); videoView.clearAnimation(); delayedStart = false; mediaReady = false; if (inFullScreen) { Bundle data = new Bundle(); data.putBoolean(FullScreenVideoUtil.VIDEOPLAYER_FULLSCREEN, false); container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_FULLSCREEN, this, data); } } /** * Returns the component's horizontal width, measured in pixels. * * @return width in pixels */ @Override @SimpleProperty public int Width() { return super.Width(); } /** * Specifies the component's horizontal width, measured in pixels. * * @param width in pixels */ @Override @SimpleProperty(userVisible = true) public void Width(int width) { super.Width(width); // Forces a layout of the ResizableVideoView videoView.changeVideoSize(width, videoView.forcedHeight); } /** * Returns the component's vertical height, measured in pixels. * * @return height in pixels */ @Override @SimpleProperty public int Height() { return super.Height(); } /** * Specifies the component's vertical height, measured in pixels. * * @param height * in pixels */ @Override @SimpleProperty(userVisible = true) public void Height(int height) { super.Height(height); // Forces a layout of the ResizableVideoView videoView.changeVideoSize(videoView.forcedWidth, height); } /** * Returns whether the VideoPlayer's video is currently being * shown in fullscreen mode or not. * @return True if video is being shown in fullscreen. False otherwise. */ @SimpleProperty public boolean FullScreen() { return inFullScreen; } /** * Sets whether the video should be shown in fullscreen or not. * * @param value If True, the video will be shown in fullscreen. * If False and {@link VideoPlayer#FullScreen()} returns True, fullscreen * mode will be exited. If False and {@link VideoPlayer#FullScreen()} * returns False, nothing occurs. */ @SimpleProperty(userVisible = true) public void FullScreen(boolean value) { if (value && (SdkLevel.getLevel() <= SdkLevel.LEVEL_DONUT)) { container.$form().dispatchErrorOccurredEvent(this, "FullScreen(true)", ErrorMessages.ERROR_VIDEOPLAYER_FULLSCREEN_UNSUPPORTED); return; } if (value != inFullScreen) { if (value) { Bundle data = new Bundle(); data.putInt(FullScreenVideoUtil.VIDEOPLAYER_POSITION, videoView.getCurrentPosition()); data.putBoolean(FullScreenVideoUtil.VIDEOPLAYER_PLAYING, videoView.isPlaying()); videoView.pause(); data.putBoolean(FullScreenVideoUtil.VIDEOPLAYER_FULLSCREEN, true); data.putString(FullScreenVideoUtil.VIDEOPLAYER_SOURCE, sourcePath); Bundle result = container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_FULLSCREEN, this, data); if (result.getBoolean(FullScreenVideoUtil.ACTION_SUCESS)) { inFullScreen = true; } else { inFullScreen = false; container.$form().dispatchErrorOccurredEvent(this, "FullScreen", ErrorMessages.ERROR_VIDEOPLAYER_FULLSCREEN_UNAVAILBLE, ""); } } else { Bundle values = new Bundle(); values.putBoolean(FullScreenVideoUtil.VIDEOPLAYER_FULLSCREEN, false); Bundle result = container.$form().fullScreenVideoAction( FullScreenVideoUtil.FULLSCREEN_VIDEO_ACTION_FULLSCREEN, this, values); if (result.getBoolean(FullScreenVideoUtil.ACTION_SUCESS)) { fullScreenKilled((Bundle) result); } else { inFullScreen = true; container.$form().dispatchErrorOccurredEvent(this, "FullScreen", ErrorMessages.ERROR_VIDEOPLAYER_FULLSCREEN_CANT_EXIT, ""); } } } } /** * Notify this VideoPlayer that its video is no longer being shown * in fullscreen. * @param data See {@link com.google.appinventor.components.runtime.util.FullScreenVideoUtil} * for an example of what data should contain. */ public void fullScreenKilled(Bundle data) { inFullScreen = false; String newSource = data.getString(FullScreenVideoUtil.VIDEOPLAYER_SOURCE); if (!newSource.equals(sourcePath)) { Source(newSource); } videoView.setVisibility(View.VISIBLE); videoView.requestLayout(); SeekTo(data.getInt(FullScreenVideoUtil.VIDEOPLAYER_POSITION)); if (data.getBoolean(FullScreenVideoUtil.VIDEOPLAYER_PLAYING)) { Start(); } } /** * Get the value passed in {@link VideoPlayer#Width(int)} * @return The width value. */ public int getPassedWidth() { return videoView.forcedWidth; } /** * Get the value passed in {@link VideoPlayer#Height(int)} * @return The height value. */ public int getPassedHeight() { return videoView.forcedHeight; } /** * Extends VideoView to allow resizing of the view that ignores the aspect * ratio of the video being played. * * @author Vance Turnewitsch */ class ResizableVideoView extends VideoView { private MediaPlayer mVideoPlayer; /* * Used by onMeasure to determine whether the mVideoPlayer should be used to * measure the view. */ private Boolean mFoundMediaPlayer = false; /** * Used by onMeasure to determine what type of size the VideoPlayer should * be. */ public int forcedWidth = LENGTH_PREFERRED; /** * Used by onMeasure to determine what type of size the VideoPlayer should * be. */ public int forcedHeight = LENGTH_PREFERRED; public ResizableVideoView(Context context) { super(context); } public void onMeasure(int specwidth, int specheight) { onMeasure(specwidth, specheight, 0); } private void onMeasure(final int specwidth, final int specheight, final int trycount) { // Since super.onMeasure uses the aspect ratio of the video being // played, it is not called. // http://grepcode.com/file/repository.grepcode.com/java/ext/ // com.google.android/android/2.2.2_r1/android/widget/VideoView.java // #VideoView.onMeasure%28int%2Cint%29 // Log messages in this method are not commented out for testing the // changes // on other devices. boolean scaleHeight = false; boolean scaleWidth = false; float deviceDensity = container.$form().deviceDensity(); Log.i("VideoPlayer..onMeasure", "Device Density = " + deviceDensity); Log.i("VideoPlayer..onMeasure", "AI setting dimensions as:" + forcedWidth + ":" + forcedHeight); Log.i("VideoPlayer..onMeasure", "Dimenions from super>>" + MeasureSpec.getSize(specwidth) + ":" + MeasureSpec.getSize(specheight)); // The VideoPlayer's dimensions must always be some non-zero number. int width = ComponentConstants.VIDEOPLAYER_PREFERRED_WIDTH; int height = ComponentConstants.VIDEOPLAYER_PREFERRED_HEIGHT; switch (forcedWidth) { case LENGTH_FILL_PARENT: switch (MeasureSpec.getMode(specwidth)) { case MeasureSpec.EXACTLY: case MeasureSpec.AT_MOST: width = MeasureSpec.getSize(specwidth); break; case MeasureSpec.UNSPECIFIED: try { width = ((View) getParent()).getMeasuredWidth(); } catch (ClassCastException cast) { width = ComponentConstants.VIDEOPLAYER_PREFERRED_WIDTH; } catch (NullPointerException nullParent) { width = ComponentConstants.VIDEOPLAYER_PREFERRED_WIDTH; } } break; case LENGTH_PREFERRED: if (mFoundMediaPlayer) { try { width = mVideoPlayer.getVideoWidth(); Log.i("VideoPlayer.onMeasure", "Got width from MediaPlayer>" + width); } catch (NullPointerException nullVideoPlayer) { Log.e( "VideoPlayer..onMeasure", "Failed to get MediaPlayer for width:\n" + nullVideoPlayer.getMessage()); width = ComponentConstants.VIDEOPLAYER_PREFERRED_WIDTH; } } else { } break; default: scaleWidth = true; width = forcedWidth; } if (forcedWidth <= LENGTH_PERCENT_TAG) { int cWidth = container.$form().Width(); if (cWidth == 0 && trycount < 2) { Log.d("VideoPlayer...onMeasure", "Width not stable... trying again (onMeasure " + trycount + ")"); androidUIHandler.postDelayed(new Runnable() { @Override public void run() { onMeasure(specwidth, specheight, trycount + 1); } }, 100); // Try again in 1/10 of a second setMeasuredDimension(100, 100); // We have to set something or our caller is unhappy return; } width = (int) ((float) (cWidth * (- (width - LENGTH_PERCENT_TAG)) / 100) * deviceDensity); } else if (scaleWidth) { width = (int) ((float) width * deviceDensity); } switch (forcedHeight) { case LENGTH_FILL_PARENT: switch (MeasureSpec.getMode(specheight)) { case MeasureSpec.EXACTLY: case MeasureSpec.AT_MOST: height = MeasureSpec.getSize(specheight); break; case MeasureSpec.UNSPECIFIED: // Use height from ComponentConstants // The current measuring of components ignores FILL_PARENT for height, // and does not actually fill the height of the parent container. } break; case LENGTH_PREFERRED: if (mFoundMediaPlayer) { try { height = mVideoPlayer.getVideoHeight(); Log.i("VideoPlayer.onMeasure", "Got height from MediaPlayer>" + height); } catch (NullPointerException nullVideoPlayer) { Log.e( "VideoPlayer..onMeasure", "Failed to get MediaPlayer for height:\n" + nullVideoPlayer.getMessage()); height = ComponentConstants.VIDEOPLAYER_PREFERRED_HEIGHT; } } break; default: scaleHeight = true; height = forcedHeight; } if (forcedHeight <= LENGTH_PERCENT_TAG) { int cHeight = container.$form().Height(); if (cHeight == 0 && trycount < 2) { Log.d("VideoPlayer...onMeasure", "Height not stable... trying again (onMeasure " + trycount + ")"); androidUIHandler.postDelayed(new Runnable() { @Override public void run() { onMeasure(specwidth, specheight, trycount + 1); } }, 100); // Try again in 1/10 of a second setMeasuredDimension(100, 100); // We have to set something or our caller is unhappy return; } height = (int) ((float) (cHeight * (- (height - LENGTH_PERCENT_TAG)) / 100) * deviceDensity); } else if (scaleHeight) { height = (int) ((float) height * deviceDensity); } // Forces the video playing in the VideoView to scale. // Some Android devices though will not scale the video playing. Log.i("VideoPlayer.onMeasure", "Setting dimensions to:" + width + "x" + height); getHolder().setFixedSize(width, height); setMeasuredDimension(width, height); } /** * Resize the view size and request a layout. */ public void changeVideoSize(int newWidth, int newHeight) { forcedWidth = newWidth; forcedHeight = newHeight; forceLayout(); invalidate(); } /* * Used to keep onMeasure from using the mVideoPlayer in measuring. */ public void invalidateMediaPlayer(boolean triggerRedraw) { mFoundMediaPlayer = false; mVideoPlayer = null; if (triggerRedraw) { forceLayout(); invalidate(); } } public void setMediaPlayer(MediaPlayer newMediaPlayer, boolean triggerRedraw) { mVideoPlayer = newMediaPlayer; mFoundMediaPlayer = true; if (triggerRedraw) { forceLayout(); invalidate(); } } } }