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.
This commit is contained in:
Daniel Vicory 2019-05-16 15:22:08 -07:00 committed by Thibault Malbranche
parent c0332ec607
commit d72c2ae144
3 changed files with 181 additions and 54 deletions

View File

@ -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<WebView> {
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<WebView> {
@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<Uri> filePathCallback, String acceptType) {
getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType);
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
getModule(reactContext).startPhotoPickerIntent(filePathCallback, "");
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
getModule(reactContext).startPhotoPickerIntent(filePathCallback, acceptType);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> 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<WebView> {
}
}
@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<WebView> {
((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<WebView> {
}
}
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<Uri> filePathCallback, String acceptType) {
getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType);
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
getModule(mReactContext).startPhotoPickerIntent(filePathCallback, "");
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
getModule(mReactContext).startPhotoPickerIntent(filePathCallback, acceptType);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> 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

View File

@ -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`.

View File

@ -48,6 +48,7 @@ class WebView extends React.Component<AndroidWebViewProps, State> {
javaScriptEnabled: true,
thirdPartyCookiesEnabled: true,
scalesPageToFit: true,
allowsFullscreenVideo: false,
allowFileAccess: false,
saveFormDataDisabled: false,
cacheEnabled: true,