From d72c2ae144603168ff2b900bb95b9c5c3bad8d4a Mon Sep 17 00:00:00 2001 From: Daniel Vicory Date: Thu, 16 May 2019 15:22:08 -0700 Subject: [PATCH] feat(fullscreen videos): Support fullscreen video on Android (#325) * Extract WebChromeClient from an anonymous class * Support fullscreen videos on Android Forces landscape mode while playing. * Use sticky immersive mode for fullscreen videos No longer forces landscape mode as that is a problem for portrait videos - allow the user to rotate as necessary. Only supports KitKat or greater, and falls back to leaving the status and navigation bars visible for lower than KitKat. This is the easiest way to prevent issues with resizing the video during playback. Also implement a lifecyle event listener which means if a user backgrounds the app or locks the screen with the video fullscreen, the UI visibility is re-applied. * Add allowsFullscreenVideo prop to control whether videos can be fullscreen on Android Luckily, we're able to change the WebChromeClient on demand in response to prop changes without seeming to do any harm. If you switch to disallow fullscreen, it will attempt to close the currently fullscreened video (if there is one) so users aren't stuck. I did notice a bug that if you go from fullscreen allowed, to fullscreen disallowed, the fullscreen button will remain on the video. Tapping the button will have no effect. --- .../webview/RNCWebViewManager.java | 223 +++++++++++++----- docs/Reference.md | 11 + src/WebView.android.tsx | 1 + 3 files changed, 181 insertions(+), 54 deletions(-) diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java index 849b7ee..e785fb0 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -6,12 +6,17 @@ import android.app.DownloadManager; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; +import android.graphics.Color; import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.support.annotation.RequiresApi; import android.text.TextUtils; +import android.view.Gravity; import android.view.View; +import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; +import android.view.WindowManager; import android.webkit.ConsoleMessage; import android.webkit.CookieManager; import android.webkit.DownloadListener; @@ -24,6 +29,7 @@ import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.widget.FrameLayout; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.LifecycleEventListener; @@ -106,6 +112,9 @@ public class RNCWebViewManager extends SimpleViewManager { protected static final String BLANK_URL = "about:blank"; protected WebViewConfig mWebViewConfig; + protected RNCWebChromeClient mWebChromeClient = null; + protected boolean mAllowsFullscreenVideo = false; + public RNCWebViewManager() { mWebViewConfig = new WebViewConfig() { public void configWebView(WebView webView) { @@ -137,59 +146,7 @@ public class RNCWebViewManager extends SimpleViewManager { @TargetApi(Build.VERSION_CODES.LOLLIPOP) protected WebView createViewInstance(ThemedReactContext reactContext) { RNCWebView webView = createRNCWebViewInstance(reactContext); - webView.setWebChromeClient(new WebChromeClient() { - @Override - public boolean onConsoleMessage(ConsoleMessage message) { - if (ReactBuildConfig.DEBUG) { - return super.onConsoleMessage(message); - } - // Ignore console logs in non debug builds. - return true; - } - - - @Override - public void onProgressChanged(WebView webView, int newProgress) { - super.onProgressChanged(webView, newProgress); - WritableMap event = Arguments.createMap(); - event.putDouble("target", webView.getId()); - event.putString("title", webView.getTitle()); - event.putBoolean("canGoBack", webView.canGoBack()); - event.putBoolean("canGoForward", webView.canGoForward()); - event.putDouble("progress", (float) newProgress / 100); - dispatchEvent( - webView, - new TopLoadingProgressEvent( - webView.getId(), - event)); - } - - @Override - public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { - callback.invoke(origin, true, false); - } - - protected void openFileChooser(ValueCallback filePathCallback, String acceptType) { - getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType); - } - - protected void openFileChooser(ValueCallback filePathCallback) { - getModule(reactContext).startPhotoPickerIntent(filePathCallback, ""); - } - - protected void openFileChooser(ValueCallback filePathCallback, String acceptType, String capture) { - getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType); - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { - String[] acceptTypes = fileChooserParams.getAcceptTypes(); - boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE; - Intent intent = fileChooserParams.createIntent(); - return getModule(reactContext).startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple); - } - }); + setupWebChromeClient(reactContext, webView); reactContext.addLifecycleEventListener(webView); mWebViewConfig.configWebView(webView); WebSettings settings = webView.getSettings(); @@ -454,6 +411,14 @@ public class RNCWebViewManager extends SimpleViewManager { } } + @ReactProp(name = "allowsFullscreenVideo") + public void setAllowsFullscreenVideo( + WebView view, + @Nullable Boolean allowsFullscreenVideo) { + mAllowsFullscreenVideo = allowsFullscreenVideo != null && allowsFullscreenVideo; + setupWebChromeClient((ReactContext)view.getContext(), view); + } + @ReactProp(name = "allowFileAccess") public void setAllowFileAccess( WebView view, @@ -554,10 +519,67 @@ public class RNCWebViewManager extends SimpleViewManager { ((RNCWebView) webView).cleanupCallbacksAndDestroy(); } - public RNCWebViewModule getModule(ReactContext reactContext) { + public static RNCWebViewModule getModule(ReactContext reactContext) { return reactContext.getNativeModule(RNCWebViewModule.class); } + protected void setupWebChromeClient(ReactContext reactContext, WebView webView) { + if (mAllowsFullscreenVideo) { + mWebChromeClient = new RNCWebChromeClient(reactContext, webView) { + @Override + public void onShowCustomView(View view, CustomViewCallback callback) { + if (mVideoView != null) { + callback.onCustomViewHidden(); + return; + } + + mVideoView = view; + mCustomViewCallback = callback; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + mVideoView.setSystemUiVisibility(FULLSCREEN_SYSTEM_UI_VISIBILITY); + mReactContext.getCurrentActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + } + + mVideoView.setBackgroundColor(Color.BLACK); + getRootView().addView(mVideoView, FULLSCREEN_LAYOUT_PARAMS); + mWebView.setVisibility(View.GONE); + + mReactContext.addLifecycleEventListener(this); + } + + @Override + public void onHideCustomView() { + if (mVideoView == null) { + return; + } + + mVideoView.setVisibility(View.GONE); + getRootView().removeView(mVideoView); + mCustomViewCallback.onCustomViewHidden(); + + mVideoView = null; + mCustomViewCallback = null; + + mWebView.setVisibility(View.VISIBLE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + mReactContext.getCurrentActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + } + + mReactContext.removeLifecycleEventListener(this); + } + }; + webView.setWebChromeClient(mWebChromeClient); + } else { + if (mWebChromeClient != null) { + mWebChromeClient.onHideCustomView(); + } + mWebChromeClient = new RNCWebChromeClient(reactContext, webView); + webView.setWebChromeClient(mWebChromeClient); + } + } + protected static class RNCWebViewClient extends WebViewClient { protected boolean mLastLoadFailed = false; @@ -655,6 +677,99 @@ public class RNCWebViewManager extends SimpleViewManager { } } + protected static class RNCWebChromeClient extends WebChromeClient implements LifecycleEventListener { + protected static final FrameLayout.LayoutParams FULLSCREEN_LAYOUT_PARAMS = new FrameLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER); + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + protected static final int FULLSCREEN_SYSTEM_UI_VISIBILITY = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + + protected ReactContext mReactContext; + protected View mWebView; + + protected View mVideoView; + protected WebChromeClient.CustomViewCallback mCustomViewCallback; + + public RNCWebChromeClient(ReactContext reactContext, WebView webView) { + this.mReactContext = reactContext; + this.mWebView = webView; + } + + @Override + public boolean onConsoleMessage(ConsoleMessage message) { + if (ReactBuildConfig.DEBUG) { + return super.onConsoleMessage(message); + } + // Ignore console logs in non debug builds. + return true; + } + + @Override + public void onProgressChanged(WebView webView, int newProgress) { + super.onProgressChanged(webView, newProgress); + WritableMap event = Arguments.createMap(); + event.putDouble("target", webView.getId()); + event.putString("title", webView.getTitle()); + event.putBoolean("canGoBack", webView.canGoBack()); + event.putBoolean("canGoForward", webView.canGoForward()); + event.putDouble("progress", (float) newProgress / 100); + dispatchEvent( + webView, + new TopLoadingProgressEvent( + webView.getId(), + event)); + } + + @Override + public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { + callback.invoke(origin, true, false); + } + + protected void openFileChooser(ValueCallback filePathCallback, String acceptType) { + getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType); + } + + protected void openFileChooser(ValueCallback filePathCallback) { + getModule(mReactContext).startPhotoPickerIntent(filePathCallback, ""); + } + + protected void openFileChooser(ValueCallback filePathCallback, String acceptType, String capture) { + getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { + String[] acceptTypes = fileChooserParams.getAcceptTypes(); + boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE; + Intent intent = fileChooserParams.createIntent(); + return getModule(mReactContext).startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple); + } + + @Override + public void onHostResume() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && mVideoView != null && mVideoView.getSystemUiVisibility() != FULLSCREEN_SYSTEM_UI_VISIBILITY) { + mVideoView.setSystemUiVisibility(FULLSCREEN_SYSTEM_UI_VISIBILITY); + } + } + + @Override + public void onHostPause() { } + + @Override + public void onHostDestroy() { } + + protected ViewGroup getRootView() { + return (ViewGroup) mReactContext.getCurrentActivity().findViewById(android.R.id.content); + } + } + /** * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order * to call {@link WebView#destroy} on activity destroy event and also to clear the client diff --git a/docs/Reference.md b/docs/Reference.md index 284b8ee..0d426fb 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -30,6 +30,7 @@ This document lays out the current public properties and methods for the React N - [`mixedContentMode`](Reference.md#mixedcontentmode) - [`thirdPartyCookiesEnabled`](Reference.md#thirdpartycookiesenabled) - [`userAgent`](Reference.md#useragent) +- [`allowsFullscreenVideo`](Reference.md#allowsfullscreenvideo) - [`allowsInlineMediaPlayback`](Reference.md#allowsinlinemediaplayback) - [`bounces`](Reference.md#bounces) - [`overScrollMode`](Reference.md#overscrollmode) @@ -591,6 +592,16 @@ Sets the user-agent for the `WebView`. This will only work for iOS if you are us --- +### `allowsFullscreenVideo` + +Boolean that determines whether videos are allowed to be played in fullscreen. The default value is `false`. + +| Type | Required | Platform | +| ---- | -------- | -------- | +| bool | No | Android | + +--- + ### `allowsInlineMediaPlayback` Boolean that determines whether HTML5 videos play inline or use the native full-screen controller. The default value is `false`. diff --git a/src/WebView.android.tsx b/src/WebView.android.tsx index 412bcba..36cf7e2 100644 --- a/src/WebView.android.tsx +++ b/src/WebView.android.tsx @@ -48,6 +48,7 @@ class WebView extends React.Component { javaScriptEnabled: true, thirdPartyCookiesEnabled: true, scalesPageToFit: true, + allowsFullscreenVideo: false, allowFileAccess: false, saveFormDataDisabled: false, cacheEnabled: true,