/* Copyright (c) 2011, 2012, Code Aurora Forum. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following * disclaimer in the documentation and/or other materials provided * with the distribution. * * Neither the name of Code Aurora Forum, Inc. nor the names of its * contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package android.webkit; import android.Manifest.permission; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Point; import android.graphics.SurfaceTexture; import android.media.MediaMetadataRetriever; import android.media.MediaPlayer; import android.media.Metadata; import android.net.Uri; import android.opengl.GLES20; import android.os.PowerManager; import android.util.Log; import android.view.Display; import android.view.Gravity; import android.view.MotionEvent; import android.view.Surface; import android.view.TextureView; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.MediaController; import android.widget.MediaController.MediaPlayerControl; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.Timer; import java.util.TimerTask; /** * @hide This is only used by the browser */ public class HTML5VideoView implements MediaPlayer.OnPreparedListener, MediaPlayerControl, View.OnTouchListener, TextureView.SurfaceTextureListener, SurfaceTexture.OnFrameAvailableListener, MediaPlayer.OnVideoSizeChangedListener { private static final String LOGTAG = "HTML5VideoView"; private static final String COOKIE = "Cookie"; private static final String HIDE_URL_LOGS = "x-hide-urls-from-log"; private static final long ANIMATION_DURATION = 750L; // in ms // For handling the seekTo before prepared, we need to know whether or not // the video is prepared. Therefore, we differentiate the state between // prepared and not prepared. // When the video is not prepared, we will have to save the seekTo time, // and use it when prepared to play. // NOTE: these values are in sync with VideoLayerAndroid.h in webkit side. // Please keep them in sync when changed. static final int STATE_INITIALIZED = 0; static final int STATE_PREPARING = 1; static final int STATE_PREPARED = 2; static final int STATE_PLAYING = 3; static final int STATE_BUFFERING = 4; static final int STATE_RELEASED = 5; static final int ANIMATION_STATE_NONE = 0; static final int ANIMATION_STATE_STARTED = 1; static final int ANIMATION_STATE_FINISHED = 2; private int mAnimationState; private HTML5VideoViewProxy mProxy; // Save the seek time when not prepared. This can happen when switching // video besides initial load. private int mSaveSeekTime; private MediaPlayer mPlayer; private int mCurrentState; // We need to save such info. private Uri mUri; private Map<String, String> mHeaders; // The timer for timeupate events. // See http://www.whatwg.org/specs/web-apps/current-work/#event-media-timeupdate private Timer mTimer; private boolean mIsFullscreen; protected boolean mPauseDuringPreparing; // The spec says the timer should fire every 250 ms or less. private static final int TIMEUPDATE_PERIOD = 250; // ms private int mVideoWidth; private int mVideoHeight; private int mDuration; private int mFullscreenWidth; private int mFullscreenHeight; private float mInlineX; private float mInlineY; private float mInlineWidth; private float mInlineHeight; private Point mDisplaySize; private int[] mWebViewLocation; // The Media Controller only used for full screen mode private MediaController mMediaController; // Data only for MediaController private boolean mCanSeekBack; private boolean mCanSeekForward; private boolean mCanPause; private int mCurrentBufferPercentage; // The progress view. private View mProgressView; // The container for the progress view and video view private FrameLayout mLayout; private SurfaceTexture mSurfaceTexture; private VideoTextureView mTextureView; // m_textureNames is the texture bound with this SurfaceTexture. private int[] mTextureNames; private boolean mNeedsAttachToInlineGlContext; // common Video control FUNCTIONS: public void start() { if (mCurrentState == STATE_PREPARED) { // When replaying the same video, there is no onPrepared call. // Therefore, the timer should be set up here. if (mTimer == null) { mTimer = new Timer(); mTimer.schedule(new TimeupdateTask(mProxy), TIMEUPDATE_PERIOD, TIMEUPDATE_PERIOD); } mPlayer.start(); setPlayerBuffering(false); // Notify webkit MediaPlayer that video is playing to make sure // webkit MediaPlayer is always synchronized with the proxy. // This is particularly important when using the fullscreen // MediaController. mProxy.dispatchOnPlaying(); if (mMediaController != null) mMediaController.show(); } else setStartWhenPrepared(true); } public void pause() { if (isPlaying()) { mPlayer.pause(); } else if (mCurrentState == STATE_PREPARING) { mPauseDuringPreparing = true; } // Notify webkit MediaPlayer that video is paused to make sure // webkit MediaPlayer is always synchronized with the proxy // This is particularly important when using the fullscreen // MediaController. mProxy.dispatchOnPaused(); if (mMediaController != null) mMediaController.show(0); // Delete the Timer to stop it since there is no stop call. if (mTimer != null) { mTimer.purge(); mTimer.cancel(); mTimer = null; } } public void attachToInlineGlContextIfNeeded() { if (mNeedsAttachToInlineGlContext && !mIsFullscreen && (mCurrentState == STATE_PREPARED || mCurrentState == STATE_PREPARING || mCurrentState == STATE_PLAYING)) { // Attach the previous GL texture try { mSurfaceTexture.attachToGLContext(getTextureName()); mNeedsAttachToInlineGlContext = false; } catch (RuntimeException e) { // This can occur when the EGL context has been detached from this view. // Just try to re-attach at a later time. } } } public int getDuration() { if (mCurrentState == STATE_PREPARED) { return mPlayer.getDuration(); } else { return -1; } } public int getCurrentPosition() { if (mCurrentState == STATE_PREPARED) { return mPlayer.getCurrentPosition(); } return 0; } public void seekTo(int pos) { if (mCurrentState == STATE_PREPARED) mPlayer.seekTo(pos); else mSaveSeekTime = pos; } public boolean isPlaying() { if (mCurrentState == STATE_PREPARED) { return mPlayer.isPlaying(); } else { return false; } } public void release() { if (mCurrentState != STATE_RELEASED) { stopPlayback(); mPlayer.release(); mSurfaceTexture.release(); } mCurrentState = STATE_RELEASED; } public void stopPlayback() { if (mCurrentState == STATE_PREPARED) { mPlayer.stop(); } } public boolean getPauseDuringPreparing() { return mPauseDuringPreparing; } public void setVolume(float volume) { if (mCurrentState != STATE_RELEASED) { mPlayer.setVolume(volume, volume); } } // Every time we start a new Video, we create a VideoView and a MediaPlayer HTML5VideoView(HTML5VideoViewProxy proxy, int position) { mPlayer = new MediaPlayer(); mCurrentState = STATE_INITIALIZED; mProxy = proxy; mSaveSeekTime = position; mTimer = null; mPauseDuringPreparing = false; mIsFullscreen = false; mDisplaySize = new Point(); mWebViewLocation = new int[2]; } private static Map<String, String> generateHeaders(String url, HTML5VideoViewProxy proxy) { boolean isPrivate = proxy.getWebView().isPrivateBrowsingEnabled(); String cookieValue = CookieManager.getInstance().getCookie(url, isPrivate); Map<String, String> headers = new HashMap<String, String>(); if (cookieValue != null) { headers.put(COOKIE, cookieValue); } if (isPrivate) { headers.put(HIDE_URL_LOGS, "true"); } return headers; } public void setVideoURI(String uri) { mUri = Uri.parse(uri); mHeaders = generateHeaders(uri, mProxy); } // When there is a frame ready from surface texture, we should tell WebView // to refresh. @Override public void onFrameAvailable(SurfaceTexture surfaceTexture) { // TODO: This should support partial invalidation too. mProxy.getWebView().invalidate(); } public void retrieveMetadata(HTML5VideoViewProxy proxy) { MediaMetadataRetriever retriever = new MediaMetadataRetriever(); try { retriever.setDataSource(mUri.toString(), mHeaders); mVideoWidth = Integer.parseInt(retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)); mVideoHeight = Integer.parseInt(retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)); mDuration = Integer.parseInt(retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_DURATION)); proxy.updateSizeAndDuration(mVideoWidth, mVideoHeight, mDuration); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (RuntimeException e) { // RuntimeException occurs when connection is not available or // the source type is not supported (e.g. HLS). Not calling // e.printStackTrace() here since it occurs quite often. } finally { retriever.release(); } } // Listeners setup FUNCTIONS: public void setOnCompletionListener(HTML5VideoViewProxy proxy) { mPlayer.setOnCompletionListener(proxy); } private void prepareDataCommon(HTML5VideoViewProxy proxy) { try { mPlayer.setDataSource(proxy.getContext(), mUri, mHeaders); mPlayer.prepareAsync(); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (IllegalStateException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } mCurrentState = STATE_PREPARING; } public void prepareDataAndDisplayMode() { decideDisplayMode(); mPlayer.setOnCompletionListener(mProxy); mPlayer.setOnPreparedListener(this); mPlayer.setOnErrorListener(mProxy); mPlayer.setOnInfoListener(mProxy); mPlayer.setOnVideoSizeChangedListener(this); prepareDataCommon(mProxy); // TODO: This is a workaround, after b/5375681 fixed, we should switch // to the better way. if (mProxy.getContext().checkCallingOrSelfPermission(permission.WAKE_LOCK) == PackageManager.PERMISSION_GRANTED) { mPlayer.setWakeMode(mProxy.getContext(), PowerManager.FULL_WAKE_LOCK); } if (!mIsFullscreen) setInlineFrameAvailableListener(); } // This configures the SurfaceTexture OnFrameAvailableListener in inline mode private void setInlineFrameAvailableListener() { getSurfaceTexture().setOnFrameAvailableListener(this); } public int getCurrentState() { if (isPlaying()) { return STATE_PLAYING; } else { return mCurrentState; } } private final class TimeupdateTask extends TimerTask { private HTML5VideoViewProxy mProxy; public TimeupdateTask(HTML5VideoViewProxy proxy) { mProxy = proxy; } @Override public void run() { mProxy.onTimeupdate(); } } public void onPrepared(MediaPlayer mp) { mCurrentState = STATE_PREPARED; seekTo(mSaveSeekTime); if (mProxy != null) mProxy.onPrepared(mp); if (mPauseDuringPreparing || !getStartWhenPrepared()) mPauseDuringPreparing = false; else start(); if (mIsFullscreen) { attachMediaController(); if (mProgressView != null) mProgressView.setVisibility(View.GONE); } } public void decideDisplayMode() { SurfaceTexture surfaceTexture = getSurfaceTexture(); Surface surface = new Surface(surfaceTexture); mPlayer.setSurface(surface); surface.release(); } // SurfaceTexture will be created lazily here public SurfaceTexture getSurfaceTexture() { // Create the surface texture. if (mSurfaceTexture == null || mTextureNames == null) { mTextureNames = new int[1]; GLES20.glGenTextures(1, mTextureNames, 0); mSurfaceTexture = new SurfaceTexture(mTextureNames[0]); } return mSurfaceTexture; } public void onVideoSizeChanged(MediaPlayer mp, int width, int height) { mVideoWidth = width; mVideoHeight = height; if (mTextureView != null) { // Request layout now that mVideoWidth and mVideoHeight are known // This will trigger onMeasure to get the display size right mTextureView.requestLayout(); } if (mProxy != null) { mProxy.onVideoSizeChanged(mp, width, height); } } public int getTextureName() { if (mTextureNames != null) { return mTextureNames[0]; } else { return 0; } } // This is true only when the player is buffering and paused private boolean mPlayerBuffering = false; public boolean getPlayerBuffering() { return mPlayerBuffering; } public void setPlayerBuffering(boolean playerBuffering) { mPlayerBuffering = playerBuffering; if (mProgressView != null) switchProgressView(playerBuffering); } private void switchProgressView(boolean playerBuffering) { if (playerBuffering) mProgressView.setVisibility(View.VISIBLE); else mProgressView.setVisibility(View.GONE); } class VideoTextureView extends TextureView { public VideoTextureView(Context context, SurfaceTexture surface) { super(context); try { // Detach the inline GL context surface.detachFromGLContext(); } catch (RuntimeException e) { e.printStackTrace(); } setSurfaceTexture(surface); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { mFullscreenWidth = getDefaultSize(mVideoWidth, widthMeasureSpec); mFullscreenHeight = getDefaultSize(mVideoHeight, heightMeasureSpec); if (mVideoWidth > 0 && mVideoHeight > 0) { if ( mVideoWidth * mFullscreenHeight > mFullscreenWidth * mVideoHeight ) { mFullscreenHeight = mFullscreenWidth * mVideoHeight / mVideoWidth; } else if ( mVideoWidth * mFullscreenHeight < mFullscreenWidth * mVideoHeight ) { mFullscreenWidth = mFullscreenHeight * mVideoWidth / mVideoHeight; } } setMeasuredDimension(mFullscreenWidth, mFullscreenHeight); if (mAnimationState == ANIMATION_STATE_NONE) { // Configuring VideoTextureView to inline bounds mTextureView.setTranslationX(getInlineXOffset()); mTextureView.setTranslationY(getInlineYOffset()); mTextureView.setScaleX(getInlineXScale()); mTextureView.setScaleY(getInlineYScale()); // inline to fullscreen zoom out animation mTextureView.animate().setListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator animation) { mAnimationState = ANIMATION_STATE_FINISHED; attachMediaController(); } }); mTextureView.animate().setDuration(ANIMATION_DURATION); mAnimationState = ANIMATION_STATE_STARTED; mTextureView.animate().scaleX(1.0f).scaleY(1.0f).translationX(0.0f).translationY(0.0f); } } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); // Attach to the previous GL texture mNeedsAttachToInlineGlContext = true; attachToInlineGlContextIfNeeded(); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // Needed to update the view during orientation change when video is paused // Calling setOpaque() forces the layer to be updated setOpaque(false); setOpaque(true); } } // Note: Call this for fullscreen mode only // If MediaPlayer is prepared, enable the buttons private void attachMediaController() { // Get the capabilities of the player for this stream // This should only be called when MediaPlayer is in prepared state // Otherwise data will return invalid values if (mIsFullscreen && mCurrentState == STATE_PREPARED) { if (mMediaController == null) { MediaController mc = new FullscreenMediaController(mProxy.getContext(), mLayout); mc.setSystemUiVisibility(mLayout.getSystemUiVisibility()); mMediaController = mc; mMediaController.setMediaPlayer(this); mMediaController.setAnchorView(mTextureView); mMediaController.setEnabled(false); } Metadata data = mPlayer.getMetadata(MediaPlayer.METADATA_ALL, MediaPlayer.BYPASS_METADATA_FILTER); if (data != null) { mCanPause = !data.has(Metadata.PAUSE_AVAILABLE) || data.getBoolean(Metadata.PAUSE_AVAILABLE); mCanSeekBack = !data.has(Metadata.SEEK_BACKWARD_AVAILABLE) || data.getBoolean(Metadata.SEEK_BACKWARD_AVAILABLE); mCanSeekForward = !data.has(Metadata.SEEK_FORWARD_AVAILABLE) || data.getBoolean(Metadata.SEEK_FORWARD_AVAILABLE); } else { mCanPause = mCanSeekBack = mCanSeekForward = true; } // mMediaController status depends on the Metadata result, so put it // after reading the MetaData mMediaController.setEnabled(true); if (mAnimationState == ANIMATION_STATE_FINISHED) { // If paused, should show the controller for ever! if (getStartWhenPrepared() || isPlaying()) mMediaController.show(); else mMediaController.show(0); } } } private void toggleMediaControlsVisiblity() { if (mMediaController.isShowing()) mMediaController.hide(); else mMediaController.show(); } public boolean fullscreenExited() { return (mLayout == null); } private final WebChromeClient.CustomViewCallback mCallback = new WebChromeClient.CustomViewCallback() { public void onCustomViewHidden() { mProxy.prepareExitFullscreen(); } }; public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { } public void onSurfaceTextureUpdated(SurfaceTexture surface) { } public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { } /** * Invoked when the specified {@link SurfaceTexture} is about to be destroyed. * If returns true, no rendering should happen inside the surface texture after this method * is invoked. If returns false, the client needs to call {@link SurfaceTexture#release()}. * * @param surface The surface about to be destroyed */ public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { // Tells the TextureView not to free the buffer return false; } public void enterFullscreenVideoState(WebViewClassic webView, float x, float y, float w, float h) { if (mIsFullscreen == true) return; mIsFullscreen = true; mAnimationState = ANIMATION_STATE_NONE; mCurrentBufferPercentage = 0; mPlayer.setOnBufferingUpdateListener(mBufferingUpdateListener); mInlineX = x; mInlineY = y; mInlineWidth = w; mInlineHeight = h; assert(mSurfaceTexture != null); mTextureView = new VideoTextureView(mProxy.getContext(), getSurfaceTexture()); mTextureView.setOnTouchListener(this); mTextureView.setFocusable(true); mTextureView.setFocusableInTouchMode(true); mTextureView.requestFocus(); mLayout = new FrameLayout(mProxy.getContext()); FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER); mTextureView.setVisibility(View.VISIBLE); mTextureView.setSurfaceTextureListener(this); mLayout.addView(mTextureView, layoutParams); mLayout.setVisibility(View.VISIBLE); WebChromeClient client = webView.getWebChromeClient(); if (client != null) { client.onShowCustomView(mLayout, mCallback); // Plugins like Flash will draw over the video so hide // them while we're playing. if (webView.getViewManager() != null) webView.getViewManager().hideAll(); // Add progress view mProgressView = client.getVideoLoadingProgressView(); if (mProgressView != null) { mLayout.addView(mProgressView, layoutParams); if (mCurrentState != STATE_PREPARED) mProgressView.setVisibility(View.VISIBLE); else mProgressView.setVisibility(View.GONE); } } } public void exitFullscreenVideoState(float x, float y, float w, float h) { if (mIsFullscreen == false) { return; } mIsFullscreen = false; mInlineX = x; mInlineY = y; mInlineWidth = w; mInlineHeight = h; // Don't show the controller after exiting the full screen. if (mMediaController != null) { mMediaController.hide(); mMediaController = null; } if (mAnimationState == ANIMATION_STATE_STARTED) { mTextureView.animate().cancel(); finishExitingFullscreen(); } else { // fullscreen to inline zoom in animation mTextureView.animate().setListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator animation) { finishExitingFullscreen(); } }); mTextureView.animate().setDuration(ANIMATION_DURATION); mTextureView.animate().scaleX(getInlineXScale()).scaleY(getInlineYScale()).translationX(getInlineXOffset()).translationY(getInlineYOffset()); } } public boolean isFullscreenMode() { return mIsFullscreen; } // MediaController FUNCTIONS: public boolean canPause() { return mCanPause; } public boolean canSeekBackward() { return mCanSeekBack; } public boolean canSeekForward() { return mCanSeekForward; } public int getBufferPercentage() { if (mPlayer != null) { return mCurrentBufferPercentage; } return 0; } private boolean mStartWhenPrepared = false; public void setStartWhenPrepared(boolean willPlay) { mStartWhenPrepared = willPlay; } public boolean getStartWhenPrepared() { return mStartWhenPrepared; } public void showControllerInFullscreen() { if (mMediaController != null) { mMediaController.show(0); } } // Other listeners functions: private MediaPlayer.OnBufferingUpdateListener mBufferingUpdateListener = new MediaPlayer.OnBufferingUpdateListener() { public void onBufferingUpdate(MediaPlayer mp, int percent) { mCurrentBufferPercentage = percent; } }; public boolean onTouch(View v, MotionEvent event) { if (mIsFullscreen && mMediaController != null) toggleMediaControlsVisiblity(); return false; } static class FullscreenMediaController extends MediaController { View mVideoView; public FullscreenMediaController(Context context, View video) { super(context); mVideoView = video; } @Override public void show() { super.show(); if (mVideoView != null) { mVideoView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); } } @Override public void hide() { if (mVideoView != null) { mVideoView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); } super.hide(); } } private float getInlineXOffset() { updateDisplaySize(); if (mInlineWidth < 0 || mInlineHeight < 0) return 0; else return mInlineX + mWebViewLocation[0] - (mDisplaySize.x - mInlineWidth) / 2; } private float getInlineYOffset() { updateDisplaySize(); if (mInlineWidth < 0 || mInlineHeight < 0) return 0; else return mInlineY + mWebViewLocation[1] - (mDisplaySize.y - mInlineHeight) / 2; } private float getInlineXScale() { if (mInlineWidth < 0 || mInlineHeight < 0 || mFullscreenWidth == 0) return 0; else return mInlineWidth / mFullscreenWidth; } private float getInlineYScale() { if (mInlineWidth < 0 || mInlineHeight < 0 || mFullscreenHeight == 0) return 0; else return mInlineHeight / mFullscreenHeight; } private void updateDisplaySize() { WindowManager wm = (WindowManager)mProxy.getContext().getSystemService(Context.WINDOW_SERVICE); Display display = wm.getDefaultDisplay(); display.getSize(mDisplaySize); mProxy.getWebView().getWebView().getLocationOnScreen(mWebViewLocation); mWebViewLocation[1] += mProxy.getWebView().getVisibleTitleHeight(); } private void finishExitingFullscreen() { mProxy.dispatchOnStopFullscreen(); mLayout.removeView(mTextureView); mTextureView = null; if (mProgressView != null) { mLayout.removeView(mProgressView); mProgressView = null; } mLayout = null; // Re enable plugin views. mProxy.getWebView().getViewManager().showAll(); // Set the frame available listener back to the inline listener setInlineFrameAvailableListener(); } }